diff --git a/.codex/skills/changelog-release/SKILL.md b/.codex/skills/changelog-release/SKILL.md new file mode 100644 index 00000000..1268c5d0 --- /dev/null +++ b/.codex/skills/changelog-release/SKILL.md @@ -0,0 +1,58 @@ +--- +name: changelog-release +description: Update CHANGELOG.md (Keep a Changelog) and bump pyproject.toml version consistently with repo policy. +metadata: + short-description: Update changelog + version +--- + +## When to use + +Use this skill when a change is user-visible or release-relevant (features, fixes, behavior changes, deprecations, removals, security). + +## Files + +* `CHANGELOG.md` +* `pyproject.toml` + +If `CHANGELOG.md` does not exist, create a stub file and note that it was missing. + +## Keep a Changelog rules (repo policy) + +* Use the heading format: `## [x.y.z] - YYYY-MM-DD` +* Allowed sections: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security` +* Historical entries are never modified. +* Each bullet: + * begins with a lowercase imperative verb (e.g., “add”, “fix”, “remove”, “deprecate”) + * uses valid Markdown list syntax + +## Procedure + +1) Determine whether the change warrants a version bump. + * If uncertain, default to *not* bumping and explicitly state uncertainty; do not guess silently. +2) If bumping: + * Update `pyproject.toml` version to the new `x.y.z`. +3) Update `CHANGELOG.md`: + * Add a new topmost entry for the new version/date. + * Place changes under the correct section(s). + * Do not edit older entries. +4) Ensure changelog content matches actual code changes: + * No speculative bullets. + * No missing bullets for significant user-visible changes. + +## Version selection guidance (pragmatic) + +Use semantic versioning heuristics unless the repository specifies otherwise: + +* PATCH: bug fix, internal refactor with no behavior change, test-only changes (often no release) +* MINOR: additive feature, new CLI option, backwards-compatible behavior enhancement +* MAJOR: breaking change, removal, incompatible behavior change + +If the repo already uses a different scheme, follow the existing precedent. + +## Output discipline + +When reporting the changelog update: + +* Show the exact new changelog entry you added. +* Show the `pyproject.toml` version line that changed. +* Keep paths POSIX-style and sort any lists deterministically. diff --git a/.codex/skills/dependencies/SKILL.md b/.codex/skills/dependencies/SKILL.md new file mode 100644 index 00000000..b3df1ba5 --- /dev/null +++ b/.codex/skills/dependencies/SKILL.md @@ -0,0 +1,68 @@ +--- +name: dependencies +description: Add/remove Python dependencies via uv while meeting repo policy (justification, tests, determinism). +metadata: + short-description: Manage deps via uv +--- + +## Policy requirements (from AGENTS.md) + +* Use `uv` for all package management, including adding/removing dependencies. +* Do not add new dependencies without an inline comment justifying the change. +* Prefer existing, popular, well-supported libraries when appropriate. + +## When to use + +Use this skill whenever you consider introducing a new third-party library, or removing/upgrading one. + +## Decision procedure before adding a dependency + +1) Confirm the need: + * Is the functionality truly non-core to the project, or not highly customized? +2) Prefer existing solutions: + * Standard library + * Existing project dependencies +3) If adding a dependency is still justified: + * Choose a well-supported library with stable maintenance and good adoption. + * Minimize dependency surface area (avoid pulling in large stacks for small tasks). + +## Required in-code justification + +When introducing a new dependency, add an inline comment near the first usage explaining: + +* why a third-party dependency is necessary (vs stdlib / existing deps) +* why this specific library was chosen +* any constraints (performance, determinism, portability) + +Keep the comment brief but specific. + +## Commands + +Use `uv` for dependency changes. Prefer to run through `just setup` afterwards to refresh `.venv` if needed. + +Typical flows: + +* Add dependency: `uv add ` +* Add dev dependency: `uv add --dev ` (if your project uses this convention) +* Remove dependency: `uv remove ` +* Sync environment: `uv sync` (or `just setup`) + +Do not add or remove dependencies in ways that bypass `uv` (e.g., editing lockfiles directly) unless explicitly instructed. + +## Validation requirements after dependency changes + +After modifying dependencies: + +1) Run the standard validation pipeline (use `quality-gates`). +2) Add or update tests if the dependency supports new behavior or replaces custom logic. +3) Ensure determinism: + * avoid time-dependent behavior introduced by the library + * avoid environment-dependent defaults + +## Reporting + +When presenting the change: + +* State the dependency added/removed and the reason (consistent with the inline comment). +* Identify any files updated by `uv` (lockfile, `pyproject.toml`, etc.). +* Confirm that `quality-gates` passed (or report failures precisely). diff --git a/.codex/skills/patch-only-fallback/SKILL.md b/.codex/skills/patch-only-fallback/SKILL.md new file mode 100644 index 00000000..73a9f10b --- /dev/null +++ b/.codex/skills/patch-only-fallback/SKILL.md @@ -0,0 +1,31 @@ +--- +name: patch-only-fallback +description: Required behavior when shell/tool execution is unavailable - produce a patch and expected command outcomes and halt. +metadata: + short-description: Patch-only mode +--- + +## When to use + +Use this skill whenever: + +* shell access is unavailable, or +* required tooling is missing/misconfigured (notably `.venv/bin/ty`), or +* you cannot run validation commands required by `AGENTS.md`. + +## Required behavior + +1) Emit a Markdown-formatted patch containing the proposed edits. +2) Describe what you would run (exact commands) to validate the change: + * `.venv/bin/ruff format src/ tests/` + * `.venv/bin/ruff check src/ tests/` + * `.venv/bin/ty check src/ tests/` + * `.venv/bin/pytest` +3) Describe the *expected* outcomes at a high level (e.g., “ruff clean”, “tests pass”), but do not fabricate logs. +4) Halt execution (do not proceed as if the checks ran). + +## Output constraints + +* Use POSIX-style paths. +* Sort file paths deterministically in the patch and any enumerations. +* Do not include ANSI styling. diff --git a/.codex/skills/quality-gates/SKILL.md b/.codex/skills/quality-gates/SKILL.md new file mode 100644 index 00000000..aa9e28fd --- /dev/null +++ b/.codex/skills/quality-gates/SKILL.md @@ -0,0 +1,88 @@ +--- +name: quality-gates +description: Run the repository’s standard validation pipeline (ruff/ty/pytest) and report results deterministically. +metadata: + short-description: Run lint/format/typecheck/tests +--- + +## Scope + +Use this skill after making any change under `src/` or `tests/` (including refactors), and before presenting a final diff. + +This skill operationalizes the requirements in `AGENTS.md`: + +* Linting: `.venv/bin/ruff check src/ tests/` +* Formatting: `.venv/bin/ruff format src/ tests/` +* Type checking: `.venv/bin/ty check src/ tests/` (with required fallback if missing) +* Testing: `.venv/bin/pytest` + +Prefer running `just` recipes when available, because the `justfile` is the canonical automation entrypoint for this repo. + +## Allowed paths and determinism + +* Only edit files under `src/`, `tests/`, or `TODO.md` unless explicitly instructed otherwise. +* Use POSIX-style paths (`/`) in output and JSON. +* Sort file paths deterministically in reports (lexicographic). +* Do not emit ANSI styling in machine-readable output. + +## Execution order + +Run steps in this order: + +1) Environment/bootstrap (only if needed) +2) Formatting +3) Linting +4) Type checking +5) Tests + +Rationale: formatting first reduces churn; type errors are usually faster to fix than test failures; tests last. + +## Commands + +### Preferred (via `just`) + +Run: + +* `just setup` (only if dependencies or `.venv` are missing/outdated) +* `just format` (auto-format) or `just format-no-fix` (check only; do not modify) +* `just lint` (auto-fix where possible) or `just lint-no-fix` (check only; do not modify) +* `just typecheck` +* `just test-strict` + +If you ran auto-fix steps (`just format` / `just lint`), rerun the corresponding “no-fix” check to confirm a clean state. + +### Direct (if `just` is unavailable) + +Run: + +* `.venv/bin/ruff format src/ tests/` +* `.venv/bin/ruff check src/ tests/` +* `.venv/bin/ty check src/ tests/` +* `.venv/bin/pytest` + +## Required fallback: missing `ty` + +If `.venv/bin/ty` is not present or not executable: + +1) Record a failure notice: "`ty` not found at .venv/bin/ty; cannot complete required static typing gate." +2) Do not attempt to “approximate” type checking with another tool unless explicitly instructed. +3) Emit proposed changes as a Markdown patch and halt further execution. + +Reference: `patch-only-fallback` skill. + +## Failure handling and reporting + +If any step fails: + +* Identify the failing command and include its exit status. +* Provide the smallest actionable summary of failures (first error lines, failing test names). +* Fix issues if the failure is within scope and deterministic to resolve. +* Re-run the failed step(s) and any downstream steps that depend on them. + +## Coverage policy hook + +If the workflow produces or updates a coverage XML report (e.g., `.coverage.xml` or `coverage.xml`) and you can compare it to the repository baseline: + +* If coverage decreases from the baseline, log a warning and request user confirmation before proceeding with submission. + +If you cannot determine the baseline, explicitly state that and do not claim coverage improvement. diff --git a/.gitignore b/.gitignore index 950f24e6..be42225c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ # Mutant files +.import_linter_cache/ +.ropeproject/ e2e_projects/**/mutants /mutants tests/data/**/*.py.meta @@ -55,6 +57,7 @@ output/*/index.html # Sphinx docs/_build docs/tri*.rst +site/ # tests results/ diff --git a/.grobl.toml b/.grobl.toml new file mode 100644 index 00000000..364a46a9 --- /dev/null +++ b/.grobl.toml @@ -0,0 +1,216 @@ +# ============================================ +# Files/folders whose *presence* isn't helpful +# (exclude from directory tree listings) +# ============================================ +exclude_tree = [ + ".import_linter_cache", + ".codex", + ".ropeproject", + ".readthedocs.yaml", + "*.png", + "uv.lock", + + # ---------------- grobl-specific ---------------- + ".grobl.toml", + + # ---------------- secrets ---------------- + ".envrc", + ".direnv", + ".env", # runtime secrets, not useful to include + + # ---------------- version control ---------------- + # git + ".git", + ".gitattributes", + ".gitignore", + ".gitmodules", + ".git-rewrite", + + # non-git VCS + ".hg", + ".svn", + + # ---------------- CI service metadata ---------------- + ".github", # workflows rarely helpful for AI context + + # ---------------- editor / IDE state ---------------- + ".idea", + ".vscode", + "*.sublime-workspace", + "*.sublime-project", + ".history", # VSCode local history + "*.iml", + + # ---------------- OS junk ---------------- + ".DS_Store", + "Thumbs.db", + "Icon\r", # macOS resource fork + + # ---------------- environments / dependency caches ---------------- + "__pycache__", + ".venv", + "venv", + "env", + "node_modules", + "bower_components", + ".mypy_cache", + ".pytest_cache", + ".hypothesis", + ".tox", + ".nox", + ".ruff_cache", # Ruff linter cache + ".cache", # Generic cache dir + ".pnpm-store", + ".parcel-cache", + ".yarn", # Yarn 2+ offline cache + ".yarn/cache", + ".yarn/unplugged", + ".eslintcache", + ".vscode-test", # VS Code extension e2e cache + ".ipynb_checkpoints", + + # ---------------- build artifacts ---------------- + # common JS/Java/VSCode outputs + "dist", + "build", + "out", # common JS/Java/VSCode outputs + "target", # Rust, Maven, etc. + "wheels", # Python distribution wheels + "mutants", # mutation testing artifacts + "htmlcov", # coverage HTML reports + "site/", # MkDocs static site + ".angular", + ".gradle", + "storybook-static", # Storybook build output + "public/build", # Svelte/V + + # ---------------- coverage ---------------- + ".coverage", # coverage.py raw db + ".coverage.*", # coverage.py shards + ".coverage.xml", + "coverage.xml", + + # ---------------- Apple / Xcode ---------------- + "*.xcuserdatad", + "*.xcworkspace", + "DerivedData", + "Pods", + + # ---------------- Frontend frameworks ---------------- + ".next", # Next.js build output + ".nuxt", # Nuxt build output + ".svelte-kit", # SvelteKit build output + + # ---------------- Build systems / infra ---------------- + ".terraform", + ".terraform*", + "*.tfstate", + "*.tfstate.*", + "*.tfplan", + "CMakeFiles", + "CMakeCache.txt", + "cmake-build-*", + + # ---------------- test artifacts / recordings ---------------- + "cypress/videos", + "cypress/screenshots", + "test-results", + "reports", + + # ---------------- misc fixtures ---------------- + "tests/fixtures/*.yaml", # vcrpy recordings (non-essential for AI) +] + +# ================================================= +# Files/folders where presence is useful in the tree +# but contents usually aren't (exclude from *printing*) +# ================================================= +exclude_print = [ + # ---------------- legal / boilerplate ---------------- + "LICENSE", + "LICENSE.*", + "COPYING", + "CODE_OF_CONDUCT.md", + "CONTRIBUTING.md", + + # ---------------- docs (structure useful, content too verbose) ---------------- + "docs", + + # ---------------- build artifacts (defensive) ---------------- + "dist", + "build", + "out", + + # ---------------- assets / binaries ---------------- + "*.lproj", + "*.xcassets", + "*.pdf", + "*.png", + "*.jpg", + "*.jpeg", + "*.gif", + "*.svg", + "*.webp", + "*.avif", + "*.zip", + "*.tar", + "*.tar.gz", + "*.tgz", + "*.7z", + "*.jar", + "*.war", + "*.ear", + "*.apk", + "*.ipa", + "*.aab", + "*.exe", + "*.dll", + "*.dylib", + "*.so", + "*.a", + "*.wasm", + "*.bin", + + # ---------------- lock/config files (usually too verbose) ---------------- + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "poetry.lock", + "Pipfile.lock", + "Cargo.lock", + "composer.lock", + "Gemfile.lock", + "uv.lock", + "ruff.default.toml", + "ruff.toml", + + # ---------------- tooling configs (generally low signal for problem-solving) ---------------- + ".pre-commit-config.yaml", + ".python-version", + ".sourcery.yaml", + + # ---------------- notebooks and large JSON blobs ---------------- + "*.ipynb", # often huge; show only if asked + "*.geojson", + "*.ndjson", + "*.jsonl", + + # ---------------- logs / tmp / swap ---------------- + "*.log", + "logs", + "log", + "tmp", + "temp", + ".tmp", + ".temp", + "*.swp", + "*.swo", + "*~", + "nohup.out", +] + +# ============================================ +# Tag settings +# ============================================ +include_tree_tags = "directory" +include_file_tags = "file" diff --git a/.python-version b/.python-version deleted file mode 100644 index c8cfe395..00000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.10 diff --git a/.readthedocs.yaml b/.readthedocs.yaml index c90e2a7d..cf905d9e 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,15 +1,16 @@ version: 2 -# Build documentation in the docs/ directory with Sphinx -sphinx: - configuration: docs/conf.py +# Build documentation with MkDocs +mkdocs: + configuration: mkdocs.yml # Install the package itself to build docs python: install: - method: pip path: . - + extra_requirements: + - docs build: os: ubuntu-22.04 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..8ac580ce --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,167 @@ +# AGENTS.md + +## Codex CLI skills + +This repository provides Codex CLI skills under: + +* `.codex/skills/` + +These skills encode repeatable agent procedures (quality gates, changelog/versioning, dependency policy, patch-only fallback) +that are easy to forget or apply inconsistently when kept only as prose. Use them when operating via Codex CLI: + +* `quality-gates`: run the repository’s standard validation pipeline and handle failure modes deterministically +* `changelog-release`: update `CHANGELOG.md` and bump the version in `pyproject.toml` (Keep a Changelog) +* `dependencies`: add/remove dependencies via `uv` with required in-code justification +* `patch-only-fallback`: required behavior when shell access or tool execution is unavailable + +The rules in this file remain authoritative; skills are an operational encoding of those rules. + +## Purpose + +This file defines how You, an AI coding agent (LLMs, autonomous dev tools, etc.), must operate when contributing to this project. + +## Role + +Your responsibilities include: + +* Editing Python source files under `src/` +* Creating or editing test files under `tests/` +* Preserving output determinism, testability, and extensibility +* Respecting existing CLI conventions and internal architecture + +## Tooling Requirements + +Before proposing code, validate all changes using the tools below. +When using Codex CLI, prefer invoking the `quality-gates` skill to execute this pipeline consistently. + +If any command fails due to missing executables or environment configuration, emit a diagnostic message and request clarification from the user. + +### Package Management + +* Command: `uv` +* Rules: use `uv` for all package management including adding and removing dependencies + +### Linting + +* Command: `.venv/bin/ruff check src/ tests/` +* Rules: Defined in `pyproject.toml` and any referenced config files + +### Formatting + +* Command: `.venv/bin/ruff format src/ tests/` + +### Static Typing + +* Command: `.venv/bin/ty check src/ tests/` +* Syntax: Use Python 3.13-compatible type annotations +* Constraints: Must follow `pyproject.toml` settings + +> If `ty` is not available in `.venv/bin/`, log a failure notice, emit proposed code as a Markdown patch, and halt execution. +> If `ty` is not available in `.venv/bin/`, log a failure notice, emit proposed code as a Markdown patch, and halt execution. +> (Codex CLI: this is encoded as `patch-only-fallback` and referenced by `quality-gates`.) + +### Testing + +* Command: `.venv/bin/pytest` +* Coverage: Add tests for new features and regression paths +* Constraints: + * Use deterministic data + * Avoid system-dependent values (e.g., timestamps, user paths) + * Use `.venv/bin/pytest` to generate coverage + +> If coverage decreases from the baseline in `coverage.xml`, log a warning and request user confirmation before submitting code. + +## Behavior Constraints + +* Use POSIX-style paths (`/`) in output and JSON +* Sort file paths and line groups deterministically +* Omit ANSI styling in non-human formats (e.g., JSON) +* No I/O outside of the project unless instructed. +* Maintain internal consistency across toolchain and file states +* Prefer existing, popular, well-supported libraries when appropriate + * For logic or functionality that is not core to the project, or is not highly customized, add an appropriate dependency rather than writing a custom version. + +## Logging and Progress Tracking + +### To-Do List Maintenance + +* As you complete items from `TODO.md`, mark them as complete +* If `TODO.md` is missing, create a new file and notify the user + +### Changelog Maintenance + +Follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format: + +* Example heading: `## [1.2.3] - 2025-08-02` +* Allowed sections: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security` +* Each bullet must: + + * Begin with a lowercase imperative verb (e.g., “add”, “fix”) + * Follow Markdown syntax + +Ensure: + +* Changelog matches the actual code changes +* Version in `pyproject.toml` is updated +* Historical entries are never modified +* If `CHANGELOG.md` is missing, create a stub file and note this + +Example: + +```markdown +## [1.4.0] - 2025-08-02 + +### Added +- add `--format json` CLI option for machine-readable output + +### Fixed +- fix incorrect grouping of adjacent blank lines in coverage reports +``` + +## Commit Standards + +Each commit must pass: + +* `.venv/bin/ruff check && .venv/bin/ruff format` +* `.venv/bin/ty check` +* `.venv/bin/pytest` + +Use conventional commit messages: + +* `feat: add --format json` +* `fix: handle missing tag in coverage XML` +* `test: add tests for merge_blank_gap_groups` + +Before submitting a pull request: + +* Bump the version in `pyproject.toml` if relevant +* Update `CHANGELOG.md` accordingly + +Codex CLI: changelog/versioning rules are encoded as the `changelog-release` skill. +Codex CLI: dependency policy is encoded as the `dependencies` skill. + +## Prohibited Behavior + +* Do not add new dependencies without an inline comment justifying the change +* Do not reduce test coverage unless explicitly approved +* Do not introduce non-determinism unnecessarily (e.g., random output, time-dependent data) + +## Assumptions and Capabilities + +You must assume: + +* Each task starts with only the current file state +* You must re-read `TODO.md`, and `CHANGELOG.md` before taking action on historical items + +If lacking access to shell or file I/O: + +* Emit a Markdown-formatted patch containing proposed edits +* Describe expected outputs of toolchain commands +* Wait for user confirmation before proceeding + +## Compliance + +All actions must follow this protocol unless: + +* Overridden by an explicit user instruction +* Covered by a documented exception in this file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..6085e014 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,58 @@ +# Architecture + +This document gives an overview on how NootNoot works internally. + +## Phases of `nootnoot run` + +### Generating mutants + +This phase creates a `./mutants/` directory, which will be used by all +following phases. + +We start by copying `paths_to_mutate` to `mutants/` and then mutate the +`*.py` files in there. Finally, we also copy `also_copy` paths to +`mutants/`, including the (guessed) test directories and some project +files. + +The mutated files contains the original code and the mutants. With the +`MUTANT_UNDER_TEST` environment variable, we can specify (among other +things) which mutant should be enabled. If a mutant is not enabled, it +will run the original code. + +### Collecting tests and stats + +We collect a list of all tests and execute them. In this test run, we +track which tests would execute which mutants, and how long they take. +We use both stats for performance optimizations later on. The results +are stored in `mutants/nootnoot-stats.json` and global variables. + +### Collecting mutation results + +We load mutation results from previous runs. Mutation results are loaded +from `.meta` files next to the mutated code. For instance, the results +of `mutants/foo/bar.py` will be loaded from `mutants/foo/bar.py.meta`. + +### Running clean tests + +This step verifies that the test setup works. We disable all mutants and +run all tests. As the tests use the original versions, this *should* +succeed. + +### Running forced fail test + +Here, we verify that the mutation setup works. We tell all mutants that +they should raise an Exception, when being executed, and run all tests. +We verify that at least one test failed, to ensure that enabling mutants +works, and the tests run on mutated code. + +### Running mutation testing + +We finally check, which mutations are caught by the test suite. + +For each mutant, we execute the test suite. If any of the tests fails, +we successfully killed the mutant. To optimize performance, we only +execute the tests that could cover the mutant and sort them by mutation +time. We also skip mutants, which already have a result from a previous +run. + +The results are stored in the `.meta` files. diff --git a/ARCHITECTURE.rst b/ARCHITECTURE.rst deleted file mode 100644 index 8d1f04a7..00000000 --- a/ARCHITECTURE.rst +++ /dev/null @@ -1,50 +0,0 @@ -Architecture -====================== - -This document gives an overview on how Mutmut works internally. - -Phases of ``mutmut run`` ------------------------- - -Generating mutants -^^^^^^^^^^^^^^^^^^ - -This phase creates a ``./mutants/`` directory, which will be used by all following phases. - -We start by copying ``paths_to_mutate`` to ``mutants/`` and then mutate the ``*.py`` files in there. Finally, we also copy ``also_copy`` paths to ``mutants/``, including the (guessed) test directories and some project files. - -The mutated files contains the original code and the mutants. With the ``MUTANT_UNDER_TEST`` environment variable, we can specify (among other things) which mutant should be enabled. If a mutant is not enabled, it will run the original code. - - -Collecting tests and stats -^^^^^^^^^^^^^^^^^^^^^^^^^^ - -We collect a list of all tests and execute them. In this test run, we track which tests would execute which mutants, and how long they take. We use both stats for performance optimizations later on. The results are stored in ``mutants/mutmut-stats.json`` and global variables. - - -Collecting mutation results -^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -We load mutation results from previous runs. Mutation results are loaded from ``.meta`` files next to the mutated code. For instance, the results of ``mutants/foo/bar.py`` will be loaded from ``mutants/foo/bar.py.meta``. - - -Running clean tests -^^^^^^^^^^^^^^^^^^^ - -This step verifies that the test setup works. We disable all mutants and run all tests. As the tests use the original versions, this *should* succeed. - - -Running forced fail test -^^^^^^^^^^^^^^^^^^^^^^^^ - -Here, we verify that the mutation setup works. We tell all mutants that they should raise an Exception, when being executed, and run all tests. We verify that at least one test failed, to ensure that enabling mutants works, and the tests run on mutated code. - - -Running mutation testing -^^^^^^^^^^^^^^^^^^^^^^^^ - -We finally check, which mutations are caught by the test suite. - -For each mutant, we execute the test suite. If any of the tests fails, we successfully killed the mutant. To optimize performance, we only execute the tests that could cover the mutant and sort them by mutation time. We also skip mutants, which already have a result from a previous run. - -The results are stored in the ``.meta`` files. diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 00000000..0cbe3009 --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,27 @@ +# Credits + +- Anders Hovmöller +- Felipe Pontes +- William Orr +- Trevin Gandhi +- Daniel Hahler +- Marcelo Da Cruz Pinto +- Jakub Stolarski +- Hristo Georgiev +- Savo Kovačević +- Nathan Klapstein +- Brian Skinn +- Jim Jazwiecki +- neroks +- John Vandenberg +- Luca Simonetto +- Emil Stenström +- Roxane Bellott +- Tomáš Chvátal +- Frank Hoffmann <15r10nk-> +- Éloi Rivard +- Isidro Arias +- Will Gibson +- Dominic Amato +- A_A +- Luzin Boris diff --git a/AUTHORS.rst b/AUTHORS.rst deleted file mode 100644 index 46e52cf8..00000000 --- a/AUTHORS.rst +++ /dev/null @@ -1,29 +0,0 @@ -======= -Credits -======= - -* Anders Hovmöller -* Felipe Pontes -* William Orr -* Trevin Gandhi -* Daniel Hahler -* Marcelo Da Cruz Pinto -* Jakub Stolarski -* Hristo Georgiev -* Savo Kovačević -* Nathan Klapstein -* Brian Skinn -* Jim Jazwiecki -* neroks -* John Vandenberg -* Luca Simonetto -* Emil Stenström -* Roxane Bellott -* Tomáš Chvátal -* Frank Hoffmann <15r10nk-git@polarbit.de> -* Éloi Rivard -* Isidro Arias -* Will Gibson -* Dominic Amato -* A_A -* Luzin Boris diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..e67f1cb6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,469 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [3.4.6] - 2025-12-30 + +### Added +- add structured run events for mutation sessions +- add `--format json` output for `nootnoot run` + +### Changed +- route mutation run diagnostics to stderr to keep stdout clean + +## [3.4.5] - 2025-12-30 + +### Changed +- add schema_version to stats/meta JSON for forward-compatible persistence +- write stats/meta JSON atomically to reduce corruption risk on abrupt exits +- centralize stats/meta JSON persistence logic in a dedicated module + +## [3.4.4] - 2025-12-31 + +### Changed +- replace the custom CLI spinner with `rich`’s status helper to keep progress output consistent with debug mode + +## [3.4.3] - 2025-12-30 + +### Changed +- add explicit mutation state plumbing to avoid global state coupling across runs +- remove unused hash metadata from mutant files to avoid misleading output + +### Fixed +- fix path typing mismatches in coverage helpers +- guard `nootnoot run` on platforms without `os.fork` + +## [3.4.2] - 2025-12-30 + +### Changed +- split the CLI implementation into focused command modules and expose them through `nootnoot.cli.root` +- ensure generated mutations always end with a newline so files stay well-formed + +## [3.4.1] - 2025-12-30 + +### Changed +- move cli implementation to `nootnoot.cli` while keeping `python -m nootnoot` working + +## [3.4.0] - 2025-11-19 + +### Changed + +- add action to view tests for mutant +- add basic description for all results in nootnoot browse +- add description for timeout mutants +- exit early when stats find no tests for any mutant +- support python 3.14 +- improve performance +- fix `mutate_only_covered_lines` when files are excluded from the test run +- add `pytest_add_cli_args` and `pytest_add_cli_args_test_selection` configs +- add `mutate_only_covered_lines` config option to control whether coverage.py filters mutations +- filter out identical string mutants with different values +- handle more exit codes +- disable common order-randomising pytest plugins, as that can seriously deteriorate mutation testing performance +- fix packaging issue + +## [3.3.1] - 2025-07-30 + +### Changed + +- increase threshold for mutant timeouts +- add `tests_dir` config that accepts either a single entry or a list of directories +- fix async generators +- fix bad mutations for certain string escape sequences +- improve performance +- fix various internal bugs + +## [3.3.0] - 2025-05-18 + +### Changed + +- add python 3.13 compatibility +- add argument `--show-killed` for `nootnoot browse` +- prevent accidentally importing the un-mutated original code +- handle segfault for mutant subprocesses +- add mutations for string literals +- add mutations for common string methods +- speed up mutant generation via subprocesses +- fix the `self` parameter for mutated class methods +- fix trampoline generation for function calls with 'orig' or 'mutants' as argument names. +- copy full source directory before creating mutants +- improve error message when forced fail test fails +- fix issue with spaces in the python executable path +- avoid mutating `__new__` +- annotate mutant dicts to stay compatible with Pydantic +- replace parso with LibCST + +## [3.2.3] - 2025-01-14 + +### Changed + +- avoid crash with error message on invalid imports for `src` module +- autodetect simpler project configurations with `test_*.py` directly in the directory +- handle filenames (as opposed to dirnames) in `paths_to_mutate` +- copy `setup.cfg` and `pyproject.toml` by default +- handle single line `paths_to_mutate` + +## [3.2.2] - 2024-11-20 + +### Changed + +- fix crash when running `nootnoot results` + +## [3.2.1] - 2024-11-13 + +### Changed + +- read `paths_to_mutate` from config file +- mutate `break` to `return` to avoid timeouts +- add debug mode, enabled with `debug=True` in `setup.cfg` under `[nootnoot]` +- fix new test detection, which previously detected tests when there were none and slowed down the feedback loop +- fix many additional issues + +## [3.2.0] - 2024-10-26 + +### Changed + +- implement timeouts for mutants +- add syntax highlighting to the browser diff view +- fix additional generator issues +- fix support for `src`-style project layouts +- fix bug where nootnoot recollected all tests on every run, slowing down startup + +## [3.1.0] - 2024-10-22 + +### Changed + +- handle mutation for generator functions (`yield`) correctly +- fix so that `from \_\_future\_\_` lines are always first. +- exit directly if no stats are collected, as that is a breaking error for mutation testing +- change name mangling to make mutants less likely to trigger name-based python magic, like in pytest where functions named `test\_\*` have special meaning. + +## [3.0.5] - 2024-10-20 + +### Changed + +- attempt to get the PyPI package to work + +## [3.0.4] - 2024-10-20 + +### Changed + +- attempt to get the PyPI package to work + +## [3.0.3] - 2024-10-20 + +### Changed + +- fix missing requirement in install package +- fix missing file from the install package + +## [3.0.2] - 2024-10-20 + +### Changed + +- fix bad entrypoint definition +- ignore files that can't be parsed by `parso` + +## [3.0.1] - 2024-10-20 + +### Changed + +- restore the missing distribution file so `browse` works + +## [3.0.0] - 2024-10-20 + +### Changed + +- switch the execution model to mutation schemata, enabling parallel execution +- add terminal UI +- restrict support to pytest only, enabling better integration and faster execution + +## [2.0.0] - 2020-03-26 + +### Changed + +- add a new execution model that yields modest speed improvements when using pytest +- add a special execution mode for the hammett test runner to deliver dramatic speed improvements +- drop support for python < 3.7 (use nootnoot 1.9.0 on older versions) +- improve speed further + +## [1.9.0] - 2020-03-18 + +### Changed + +- add `nootnoot run 7` to rerun mutant `7` +- add `nootnoot show ` to list all mutants for that file +- add `nootnoot run ` to run mutation testing on a specific file +- add an experimental plugin system via `nootnoot_config.py` with `init()` and `pre_mutation(context)` hooks that can skip mutants or tweak `context.config.runner` +- improve display of `nootnoot show`/`nootnoot result` +- fix a spurious mutant on assigning a local variable with type annotations + +## [1.8.1] - 2020-03-13 + +### Changed + +- rerun tests without mutation when tests have changed to avoid false positives + +## [1.8.0] - 2020-03-02 + +### Changed + +- add `nootnoot html` report generation + +## [1.7.0] - 2020-02-29 + +### Changed + +- fix multiple assignment handling where `foo = bar = baz` was broken (thanks Roxane Bellot!) +- fix incorrect mutation of the `in` operator (thanks Roxane Bellot!) +- fix bug where a mutant survived in the internal AST too long. This could cause nootnoot to apply more than one mutant at a time. +- improve startup performance drastically when resuming a mutation run +- add new experimental feature for advanced config at runtime of mutations + +## [1.6.0] - 2019-09-21 + +### Changed + +- add `nootnoot show [path to file]` command that shows all mutants for a given file +- improve error messages if .coverage file isn't usable +- add support for windows paths in tests +- use the same python executable as nootnoot is started as if possible +- drop python 2 support +- add more assignment operator mutations +- fix + +## [1.5.0] - 2019-04-10 + +### Changed + +- add mutation: None -> '' +- display all diffs for surviving mutants for a specific file with `nootnoot show all path/to/file.py` +- display all diffs for surviving mutants with `nootnoot show all` +- fix a bug with grouping of the results for `nootnoot results` +- fix bug where `nootnoot show X` sometimes showed no diff +- fix bug where `nootnoot apply X` sometimes didn't apply a mutation +- improve error message when trying to find the code +- fix incorrect help message + +## [1.4.0] - 2019-03-26 + +### Changed + +- add setting: `--test-time-base=15.0`. This flag can be used to avoid issues with timing. +- add pre- and post-mutation hooks via `--pre-mutation=command` and `--post-mutation=command` to run commands around each mutation testing round +- fix a bug with mutation of imports. +- fix missing newline at end of the output of nootnoot. +- add support for mutating only lines specified by a patch file: `--use-patch-file=foo.patch` +- fix mutation of arguments in function call. +- loosen heuristics for finding the source to mutate so more projects work out of the box +- fix mutation of arguments in function call for python 2.7. +- fix a bug where if nootnoot couldn't find the test code it thought the tests hadn't changed. Now nootnoot treats this situation as the tests always being changed. +- fix bug where the function body was skipped for mutation if a return type annotation existed + +## [1.3.1] - 2019-01-30 + +### Changed + +- fix a bug where nootnoot crashed if a file contained exactly zero bytes. + +## [1.3.0] - 2019-01-23 + +### Changed + +- fix incorrect loading of coverage data when using the `--use-coverage` flag. +- fix a bug when updating the cache. +- fix incorrect handling of source files that didn't end with a newline. + +## [1.2.0] - 2019-01-10 + +### Changed + +- provide JUnit XML output via `nootnoot junitxml` +- fix python 2 compatibility +- fix PyPy compatibility +- fix an issue where nootnoot couldn't kill the spawned test process. +- expand Travis tests to cover python2, python3, PyPy, and Windows +- adjust the return code to reflect what nootnoot found during execution +- add the `--test-time-multiplier` CLI option to tweak the detection threshold for slower mutations +- fix compatibility with Windows (thanks Marcelo Da Cruz Pinto and Savo Kovacevic) + +## [1.1.0] - 2018-12-10 + +### Changed + +- add mutant: mutate the first argument of function calls to None if it's not already None +- overhaul the cache system so it handles duplicate lines correctly + +## [1.0.1] - 2018-11-18 + +### Changed + +- fix minor UX issues: --version was broken, documentation was incorrect, and the trailing newline was missing +- cache the baseline test time to speed up restarting or rechecking mutants + +## [1.0.0] - 2018-11-12 + +### Changed + +- introduce a new user interface that is easier to understand and monitor +- introduce a new cache handling system that tracks killed mutants and retests only what changed +- ensure infinite loop detection works in Python < 3.3 +- add `--version` flag +- add a nicer error message when no `.coverage` file is found while using `--use-coverage` +- fix crash when using `--use-coverage` flag. Thanks Daniel Hahler! +- add mutation based on finding on tri.struct + +## [0.0.24] - 2018-11-04 + +### Changed + +- stop mutating type annotations +- add simple infinite loop detection via a 10x baseline timeout + +## [0.0.23] - 2018-11-03 + +### Changed + +- improve number_mutation robustness with floats (thanks Trevin Gandhi!) +- fix crash when using Python 3 typing to declare a type but not assigning to that variable + +## [0.0.22] - 2018-10-07 + +### Changed + +- handle annotated assignment in Python 3.6. Thanks William Orr! + +## [0.0.21] - 2018-08-25 + +### Changed + +- fix critical bug: nootnoot reported killed mutants as surviving and vice versa. +- fix an issue where the install failed on some systems. +- handle tests dirs spread out in the file system. This is the normal case for django projects for example. +- fix support for both python 3 and python 2 +- improve mutation fixes. +- add the ability to test a single mutation +- add a `--print-cache` command to print the cache +- turn off parso error recovery so invalid or unsupported python code raises exceptions + +## [0.0.20] - 2018-08-02 + +### Changed + +- change AST library from baron to parso +- implement usability enhancements suggested by David M. Howcraft + +## [0.0.19] - 2018-07-20 + +### Changed + +- cache mutation testing results to reduce reruns +- add mutation IDs. They are now indexed per line instead of an index for the entire file. This means you can apply your mutations in any order you see fit and the rest of the apply commands will be unaffected. + +## [0.0.18] - 2018-04-27 + +### Changed + +- fix bug where initial mutation count was wrong, which caused nootnoot to miss mutants at the end of the file +- change the mutation API to always require a `Context` object, making it easier to pass additional data to callers +- support specifying individual files to mutate (thanks Felipe Pontes!) + +## [0.0.16] - 2017-10-09 + +### Changed + +- improve error message when baron crashes a bit (fixes \#10) +- add mutation: right hand side of assignments +- fix nasty bug where applying a mutation could apply a different mutation than the one that was found during mutation testing + +## [0.0.14] - 2017-09-02 + +### Changed + +- stop assuming UNIX to fix Windows support (GitHub #9) + +## [0.0.12] - 2017-08-27 + +### Changed + +- change default runner to add `-x` flag to pytest. Could radically speed up tests if you're lucky! +- add flag: `--show-times` +- warn when a mutation triggers very long test times +- add a workaround for pytest-testmon (all tests deselected is return code 5 even though it's a success) + +## [0.0.11] - 2017-08-03 + +### Changed + +- fix bug that made nootnoot crash when setup.cfg was missing + +## [0.0.10] - 2017-07-16 + +### Changed + +- rename parameter `--testsdir` to `--tests-dir` +- refactor setup.cfg handling and add the `--dict-synonyms` command-line parameter + +## [0.0.9] - 2017-07-05 + +### Changed + +- fix dict parameter mutation bugs that mutated every parameter +- add mutation: remove the body or return 0 instead of None + +## [0.0.8] - 2017-06-28 + +### Changed + +- fix the broken PyPI version from the previous release + +## [0.0.7] - 2017-06-28 + +### Changed + +- fix bug where pragma didn't work for decorator mutations +- mutate dict literals like `dict(a=foo)` and allow declaring synonyms in setup.cfg +- fix "from x import \*" + +## [0.0.6] - 2017-06-13 + +### Changed + +- add mutation: remove decorators! +- improve status while running. This should make it easier to handle when you hit mutants that cause infinite loops. +- fix failing attempts to mutate parentheses. (Thanks Hristo Georgiev!) + +## [0.0.5] - 2017-05-06 + +### Changed + +- attempt to fix the PyPI package + +## [0.0.4] - 2017-05-06 + +### Changed + +- attempt to fix the PyPI package + +## [0.0.3] - 2017-05-05 + +### Changed + +- add python 3 support (as far as baron supports it) +- run tests without mutations first to ensure the suite is clean before mutation testing +- implement a feature to run mutations on covered lines only for existing test suites without 100% coverage +- add an error message for incorrect invocation + +## [0.0.2] - 2016-12-01 + +### Changed + +- apply numerous fixes + +## [0.0.1] - 2016-12-01 + +### Changed + +- publish the initial version diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..0f3016d3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,54 @@ +# Contributing to NootNoot + +## Setup + +First fork the repository and clone your fork. + +We use [uv]() to manage dependencies. All +`uv` commands will implicitly install the +required dependencies, however you can also explicitly install them with +`uv sync`: + +``` console +uv sync +``` + +## Running the tests + +``` console +uv run pytest +``` + +This also runs E2E tests that verify that `nootnoot +run` produces the same output as before. If your code changes +should change the output of `nootnoot run` +and this test fails, try to delete the +`snapshots/\*.json` files (as described in +the test errors). + +If pytest terminates before reporting the test failures, it likely hit a +case where nootnoot calls `os.\_exit(...)`. +Try looking at these calls first for troubleshooting. + +## Running your local version of NootNoot against a test codebase + +You can install your local version of NootNoot and run it, including any +changes you have made, as normal. + +### Codebases using pip + +``` console +python -m pip install --editable +``` + +### Codebases using Poetry + +``` console +poetry add --group dev --editable +# Install dependencies in your Poetry environment +pip install -r /requirements.txt +``` + +## Documentation about nootnoot's architecture + +Please see ARCHITECTURE.md for more details on how nootnoot works. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst deleted file mode 100644 index 69dbedf3..00000000 --- a/CONTRIBUTING.rst +++ /dev/null @@ -1,52 +0,0 @@ -Contributing to Mutmut -====================== - -Setup ------ - -First fork the repository and clone your fork. - -We use [uv](https://docs.astral.sh/uv/) to manage dependencies. -All `uv` commands will implicitly install the required dependencies, -however you can also explicitly install them with `uv sync`: - -.. code-block:: console - - uv sync - -Running the tests ------------------ - -.. code-block:: console - - uv run pytest - -This also runs E2E tests that verify that `mutmut run` produces the same output as before. If your code changes should change the output of `mutmut run` and this test fails, try to delete the `snapshots/*.json` files (as described in the test errors). - -If pytest terminates before reporting the test failures, it likely hit a case where mutmut calls `os._exit(...)`. Try looking at these calls first for troubleshooting. - -Running your local version of Mutmut against a test codebase ------------------------------------------------------------- - -You can install your local version of Mutmut and run it, including any changes you have made, as normal. - -Codebases using pip -^^^^^^^^^^^^^^^^^^^ - -.. code-block:: console - - python -m pip install --editable - -Codebases using Poetry -^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: console - - poetry add --group dev --editable - # Install dependencies in your Poetry environment - pip install -r /requirements.txt - -Documentation about mutmut's architecture ------------------------------------------ - -Please see ARCHITECTURE.rst for more details on how mutmut works. diff --git a/HISTORY.rst b/HISTORY.rst deleted file mode 100644 index 9b8b7f57..00000000 --- a/HISTORY.rst +++ /dev/null @@ -1,551 +0,0 @@ -Changelog ---------- - -3.4.0 -~~~~~ - -* Add action to view tests for mutant - -* Add basic description for all results in mutmut browse - -* Add description for timeout mutants - -* Early exit when stats find no tests for any mutant - -* Support python 3.14 - -* Performance improvements - -* Fix `mutate_only_covered_lines` when files are excluded from test run - -* Add `pytest_add_cli_args` and `pytest_add_cli_args_test_selection` configs - -* `mutate_only_covered_lines` added to config to control whether coverage.py is used to filter down mutations - -* Filter out identical string mutants with different values - -* Handle more exit codes - -* Disable common order-randomising pytest plugins, as that can seriously deteriorate mutation testing performance - -* Fixed packaging issue - - -3.3.1 -~~~~~ - -* Increased threshold for mutant timeouts - -* Added `tests_dir` config. Accepts a single entry or a list of directories. - -* Async generators fixes - -* Fixed bad mutations for certain string escape sequences - -* Performance fixes - -* Various internal bug fixes - - -3.3.0 -~~~~~ - -* Python 3.13 compatibility! - -* New argument `--show-killed` for `mutmut browse` - -* Fix to avoid accidentally importing the un-mutated original code - -* Handle segfault for mutant subprocesses - -* Added mutations for string literals - -* Added mutations for common string methods - -* Faster mutant generation via subprocesses - -* Fix `self` parameter for mutated class methods - -* Fix trampoline generation for function calls with 'orig' or 'mutants' as argument names. - -* Copy full source directory before creating mutants - -* Improved error message when forced fail test fails - -* Fixed issue with spaces in the python executable path - -* Do not mutate `__new__` - -* Annotate mutant dicts (and fixes compatibility with Pydantic) - -* Replaced parso with LibCST - - -3.2.3 -~~~~~ - -* Crash with error message on invalid imports for `src` module - -* Autodetect simpler project configurations with `test_*.py` in the dir directly - -* Handle filenames (as opposed to dirnames) in paths_to_mutate - -* Also copy `setup.cfg` and `pyproject.toml` by default - -* Handle single line `paths_to_mutate` - - -3.2.2 -~~~~~ - -* Fixed crash when running `mutmut results` - -3.2.1 -~~~~~ - -* Read `paths_to_mutate` from config file - -* Mutate `break` to `return` to avoid timeouts - -* Added debug mode. Enable with `debug=True` in `setup.cfg` under `[mutmut]` - -* Fixed new test detection. The old code incorrectly detected new tests when there were none, creating a much slower interaction loop for fixing mutants. - -* And many more fixes - -3.2.0 -~~~~~ - -* Timeouts for mutants implemented. - -* Browser: syntax highlighting for diff view - -* More fixes for generators. - -* Fix for `src`-style layout of projects. - -* Fixed bug where mutmut would recollect all tests on every run, slowing down startup. - - -3.1.0 -~~~~~ - -* Correctly handle mutation for generator functions (`yield`). - -* Fixed so that `from __future__` lines are always first. - -* If no stats are collected exit directly, as that is a breaking error for mutation testing. - -* Changed name mangling to make mutants less likely to trigger name-based python magic, like in pytest where functions named `test_*` have special meaning. - - -3.0.5 -~~~~~ - -* Another attempt to get the pypi package to work - - -3.0.4 -~~~~~ - -* Another attempt to get the pypi package to work - -3.0.3 -~~~~~ - -* Fixed missing requirement in install package - -* Fixed missing file from the install package - -3.0.2 -~~~~~ - -* Fixed bad entrypoint definition - -* Ignore files that can't be parsed by `parso` - - -3.0.1 -~~~~~ - -* Missed a file in distribution, so `browse` command was broken. - -3.0.0 -~~~~~ - -* Execution model switched to mutation schemata, which enabled parallel execution. - -* New terminal UI - -* Pytest only, which enabled better integration, cutting execution time significantly. - - -2.0.0 -~~~~~ - -* New execution model. This should result in some modest speed improvements when using pytest. - -* A special execution mode when using the hammett test runner. This is MUCH MUCH faster. Please try it! - -* Dropped support for python < 3.7. If you need to use mutmut on older versions of python, please use mutmut 1.9.0 - -* Some other speed improvements. - - -1.9.0 -~~~~~ - -* `mutmut run 7` will always rerun the mutant `7` - -* `mutmut show ` to show all mutants for that file - -* `mutmut run ` to run mutation testing on that file - -* New experimental plugin system: create a file `mutmut_config.py` in your base directory. In it you can have an `init()` function, and a `pre_mutation(context)` function. You can set `context.skip = True` to skip a mutant, and you can modify `context.config.runner`, this is useful to limit the tests. Check out the `Context` class for what information you get. - -* Better display of `mutmut show`/`mutmut result` - -* Fixed a spurious mutant on assigning a local variable with type annotations - - - -1.8.1 -~~~~~ - -* mutmut now will rerun tests without mutation when tests have changed. This avoids a common pitfall of introducing a failing test and then having all mutants killed incorrectly - - -1.8.0 (2020-03-02) -~~~~~~~~~~~~~~~~~~ - -* Added `mutmut html` report generation. - -1.7.0 (2020-02-29) -~~~~~~~~~~~~~~~~~~ - -* Bugfix for multiple assignment. Mutmut used to not handle `foo = bar = baz` correctly (Thanks Roxane Bellot!) - -* Bugfix for incorrect mutation of "in" operator (Thanks Roxane Bellot!) - -* Fixed bug where a mutant survived in the internal AST too long. This could cause mutmut to apply more than one mutant at a time. - -* Vastly improved startup performance when resuming a mutation run. - -* Added new experimental feature for advanced config at runtime of mutations - - -1.6.0 (2019-09-21) -~~~~~~~~~~~~~~~~~~ - -* Add `mutmut show [path to file]` command that shows all mutants for a given file - -* Better error messages if .coverage file isn't usable - -* Added support for windows paths in tests - -* Use the same python executable as mutmut is started as if possible - -* Dropped python 2 support - -* Added more assignment operator mutations - -* Bugfixes - - -1.5.0 (2019-04-10) -~~~~~~~~~~~~~~~~~~ - -* New mutation: None -> '' - -* Display all diffs for surviving mutants for a specific file with `mutmut show all path/to/file.py` - -* Display all diffs for surviving mutants with `mutmut show all` - -* Fixed a bug with grouping of the results for `mutmut results` - -* Fixed bug where `mutmut show X` sometimes showed no diff - -* Fixed bug where `mutmut apply X` sometimes didn't apply a mutation - -* Improved error message when trying to find the code - -* Fixed incorrect help message - -1.4.0 (2019-03-26) -~~~~~~~~~~~~~~~~~~ - -* New setting: `--test-time-base=15.0`. This flag can be used to avoid issues with timing. - -* Post and pre hooks for the mutation step: `--pre-mutation=command` and `--post-mutation=command` if you want to run some command before and after a mutation testing round. - -* Fixed a bug with mutation of imports. - -* Fixed missing newline at end of the output of mutmut. - -* Support for mutating only lines specified by a patch file: `--use-patch-file=foo.patch`. - -* Fixed mutation of arguments in function call. - -* Looser heuristics for finding the source to mutate. This should mean more projects will just work out of the box. - -* Fixed mutation of arguments in function call for python 2.7. - -* Fixed a bug where if mutmut couldn't find the test code it thought the tests hadn't changed. Now mutmut treats this situation as the tests always being changed. - -* Fixed bug where the function body was skipped for mutation if a return type annotation existed. - -* - - -1.3.1 (2019-01-30) -~~~~~~~~~~~~~~~~~~ - -* Fixed a bug where mutmut crashed if a file contained exactly zero bytes. - - -1.3.0 (2019-01-23) -~~~~~~~~~~~~~~~~~~ - -* Fixed incorrect loading of coverage data when using the `--use-coverage` flag. - -* Fixed a bug when updating the cache. - -* Fixed incorrect handling of source files that didn't end with a newline. - - -1.2.0 (2019-01-10) -~~~~~~~~~~~~~~~~~~ - -* JUnit XML output: Run `mutmut junitxml` to output the results as a JUnit compatible XML file. - -* Python 2 compatibility fixes. - -* pypy compatibility fixes. - -* Fixed an issue where mutmut couldn't kill the spawned test process. - -* Travis tests now test much more thoroughly, both python2, 3, pypy and on windows. - -* The return code of mutmut now reflects what mutmut found during execution. - -* New command line option `--test-time-multiplier` to tweak the detection threshold for mutations that make the code slower. - -* Fixed compatibility with Windows. - - -Thanks goes out Marcelo Da Cruz Pinto, Savo Kovačević, - - -1.1.0 (2018-12-10) -~~~~~~~~~~~~~~~~~~~ - -* New mutant: mutate the first argument of function calls to None if it's not already None - -* Totally overhauled cache system: now handles duplicates lines correctly. - - -1.0.1 (2018-11-18) -~~~~~~~~~~~~~~~~~~~ - -* Minor UX fixes: --version command was broken, incorrect documentation shown, missing newline at the very end. - -* Caching the baseline test time. This makes restarting/rechecking existing mutants much faster, with a small risk of that time being out of date. - - -1.0.0 (2018-11-12) -~~~~~~~~~~~~~~~~~~~ - -* Totally new user interface! Should be much easier to understand and it's easier to see that something is happening - -* Totally new cache handling. Mutmut will now know which mutants are already killed and not try them again, and it will know which mutants to retest if the tests change - -* Infinite loop detection now works in Python < 3.3 - -* Added `--version` flag - -* Nice error message when no `.coverage` file is found when using the `--use-coverage` flag - -* Fixed crash when using `--use-coverage` flag. Thanks Daniel Hahler! - -* Added mutation based on finding on tri.struct - - -0.0.24 (2018-11-04) -~~~~~~~~~~~~~~~~~~~ - -* Stopped mutation of type annotation - -* Simple infinite loop detection: timeout on 10x the baseline time - - -0.0.23 (2018-11-03) -~~~~~~~~~~~~~~~~~~~ - -* Make number_mutation more robust to floats (Thanks Trevin Gandhi!) - -* Fixed crash when using Python 3 typing to declare a type but not assigning to that variable - - - -0.0.22 (2018-10-07) -~~~~~~~~~~~~~~~~~~~ - -* Handle annotated assignment in Python 3.6. Thanks William Orr! - - -0.0.21 (2018-08-25) -~~~~~~~~~~~~~~~~~~~ - -* Fixed critical bug: mutmut reported killed mutants as surviving and vice versa. - -* Fixed an issue where the install failed on some systems. - -* Handle tests dirs spread out in the file system. This is the normal case for django projects for example. - -* Fixes for supporting both python 3 and 2. - -* Misc mutation fixes. - -* Ability to test a single mutation. - -* Feature to print the cache (--print-cache). - -* Turned off error recovery mode for parso. You will now get exceptions for invalid or unsupported python code. - - -0.0.20 (2018-08-02) -~~~~~~~~~~~~~~~~~~~ - -* Changed AST library from baron to parso - -* Some usability enhancements suggested by David M. Howcraft - - -0.0.19 (2018-07-20) -~~~~~~~~~~~~~~~~~~~ - -* Caching of mutation testing results. This is still rather primitive but can in some cases cut down on rerunning mutmut drastically. - -* New mutation IDs. They are now indexed per line instead of an index for the entire file. This means you can apply your mutations in any order you see fit and the rest of the apply commands will be unaffected. - - -0.0.18 (2018-04-27) -~~~~~~~~~~~~~~~~~~~ - -* Fixed bug where initial mutation count was wrong, which caused mutmut to miss mutants at the end of the file - -* Changed mutation API to always require a `Context` object. This makes is much easier to pass additional data out to the caller - -* Support specifying individual files to mutate (thanks Felipe Pontes!) - - -0.0.16 (2017-10-09) -~~~~~~~~~~~~~~~~~~~ - -* Improve error message when baron crashes a bit (fixes #10) - -* New mutation: right hand side of assignments - -* Fixed nasty bug where applying a mutation could apply a different mutation than the one that was found during mutation testing - - -0.0.14 (2017-09-02) -~~~~~~~~~~~~~~~~~~~ - -* Don't assume UNIX (fixes github issue #9: didn't work on windows) - - -0.0.12 (2017-08-27) -~~~~~~~~~~~~~~~~~~~ - -* Changed default runner to add `-x` flag to pytest. Could radically speed up tests if you're lucky! - -* New flag: `--show-times` - -* Now warns if a mutation triggers very long test times - -* Added a workaround for pytest-testmon (all tests deselected is return code 5 even though it's a success) - - -0.0.11 (2017-08-03) -~~~~~~~~~~~~~~~~~~~ - -* Fixed bug that made mutmut crash when setup.cfg was missing - - -0.0.10 (2017-07-16) -~~~~~~~~~~~~~~~~~~~ - -* Renamed parameter `--testsdir` to `--tests-dir` - -* Refactored handling of setup.cfg file. Much cleaner solution and adds `--dict-synonyms` command line parameter - - -0.0.9 (2017-07-05) -~~~~~~~~~~~~~~~~~~ - -* Bug with dict param mutations: it mutated all parameters, this could vastly decrease the odds of finding a mutant - -* New mutation: remove the body or return 0 instead of None - - -0.0.8 (2017-06-28) -~~~~~~~~~~~~~~~~~~ - -* Previous version had broken version on pypi - - -0.0.7 (2017-06-28) -~~~~~~~~~~~~~~~~~~ - -* Fixed bug where pragma didn't work for decorator mutations - -* Dict literals looking like `dict(a=foo)` now have mutated keys. You can also declare synonyms in setup.cfg. - -* Fix "from x import *" - - -0.0.6 (2017-06-13) -~~~~~~~~~~~~~~~~~~ - -* New mutation: remove decorators! - -* Improved status while running. This should make it easier to handle when you hit mutants that cause infinite loops. - -* Fixes failing attempts to mutate parentheses. (Thanks Hristo Georgiev!) - - -0.0.5 (2017-05-06) -~~~~~~~~~~~~~~~~~~ - -* Try to fix pypi package - - -0.0.4 (2017-05-06) -~~~~~~~~~~~~~~~~~~ - -* Try to fix pypi package - - -0.0.3 (2017-05-05) -~~~~~~~~~~~~~~~~~~ - -* Python 3 support (as far as baron supports it anyway) - -* Try running without mutations first to make sure we can run the test suite cleanly before starting mutation - -* Implemented feature to run mutation on covered lines only, this is useful for mutation testing existing tests when you don't have 100% coverage - -* Error message on incorrect invocation - - -0.0.2 (2016-12-01) -~~~~~~~~~~~~~~~~~~ - -* Tons of fixes - - -0.0.1 (2016-12-01) -~~~~~~~~~~~~~~~~~~ - -* Initial version diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 7f8adbef..00000000 --- a/LICENSE +++ /dev/null @@ -1,12 +0,0 @@ -Copyright (c) 2016, Anders Hovmöller -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - -* Neither the name of mutmut nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..bf9130c3 --- /dev/null +++ b/README.md @@ -0,0 +1,249 @@ +# nootnoot - python mutation tester + +[![image](https://github.com/boxed/nootnoot/actions/workflows/tests.yml/badge.svg)](https://github.com/boxed/nootnoot/actions/workflows/tests.yml) + +[![Documentation Status](https://readthedocs.org/projects/nootnoot/badge/?version=latest)](https://nootnoot.readthedocs.io/en/latest/?badge=latest) + +NootNoot is a mutation testing system for Python, with a strong focus on +ease of use. If you don't know what mutation testing is try starting +with [this +article](https://kodare.net/2016/12/01/nootnoot-a-python-mutation-testing-system.html). + +Some highlight features: + +- Found mutants can be applied on disk with a simple command making it + very easy to work with the results +- Remembers work that has been done, so you can work incrementally +- Knows which tests to execute, speeding up mutation testing +- Interactive terminal based UI +- Parallel and fast execution + +![image](browse_screenshot.png) + +If you want to mutate code outside of functions, you can try using +nootnoot 2, which has a different execution model than nootnoot 3+. + +## Requirements + +NootNoot must be run on a system with `fork` +support. This means that if you want to run on windows, you must run +inside WSL. + +## Install and run + +You can get started with a simple: + +``` console +pip install nootnoot +nootnoot run +``` + +This will by run pytest on tests in the "tests" or "test" folder and it +will try to figure out where the code to mutate is. + +You can stop the mutation run at any time and nootnoot will restart where +you left off. It will continue where it left off, and re-test functions +that were modified since last run. + +To work with the results, use `nootnoot +browse` where you can see the mutants, retest them when you've +updated your tests. + +You can also write a mutant to disk from the +`browse` interface, or via +`nootnoot apply `. You should +**REALLY** have the file you mutate under source code control and +committed before you apply a mutant! + +If during the installation you get an error for the +`libcst` dependency mentioning the lack of +a rust compiler on your system, it is because your architecture does not +have a prebuilt binary for `libcst` and it +requires both `rustc` and +`cargo` from the [rust +toolchain](https://www.rust-lang.org/tools/install) to be built. This +is known for at least the `x86_64-darwin` +architecture. + +## Wildcards for testing mutants + +Unix filename pattern matching style on mutants is supported. Example: + +``` console +nootnoot run "my_module*" +nootnoot run "my_module.my_function*" +``` + +In the `browse` TUI you can press +`f` to retest a function, and +`m` to retest an entire module. + +## Configuration + +In `setup.cfg` in the root of your project +you can configure nootnoot if you need to: + +``` ini +[nootnoot] +paths_to_mutate=src/ +pytest_add_cli_args_test_selection=tests/ +``` + +If you use `pyproject.toml`, you must +specify the paths as array in a +`tool.nootnoot` section: + +``` toml +[tool.nootnoot] +paths_to_mutate = [ "src/" ] +pytest_add_cli_args_test_selection= [ "tests/" ] +``` + +See below for more options for configuring nootnoot. + +### "also copy" files + +To run the full test suite some files are often needed above the tests +and the source. You can configure to copy extra files that you need by +adding directories and files to `also_copy` +in your `setup.cfg`: + +``` ini +also_copy= + iommi/snapshots/ + conftest.py +``` + +### Limit stack depth + +In big code bases some functions are called incidentally by huge swaths +of the codebase, but you really don't want tests that hit those +executions to count for mutation testing purposes. Incidentally tested +functions lead to slow mutation testing as hundreds of tests can be +checked for things that should have clean and fast unit tests, and it +leads to bad test suites as any introduced bug in those base functions +will lead to many tests that fail which are hard to understand how they +relate to the function with the change. + +You can configure nootnoot to only count a test as being relevant for a +function if the stack depth from the test to the function is below some +limit. In your `setup.cfg` add: + +``` ini +max_stack_depth=8 +``` + +A lower value will increase mutation speed and lead to more localized +tests, but will also lead to more surviving mutants that would otherwise +have been caught. + +### Exclude files from mutation + +You can exclude files from mutation in `setup.cfg`: + +``` +do_not_mutate= + *__tests.py +``` + +### Enable coverage.py filtering of lines to mutate + +By default, nootnoot will mutate only functions that are called. But, if +you would like a finer grained (line-level) check for coverage, nootnoot +can use coverage.py to do that. + +If you only want to mutate lines that are called (according to +coverage.py), you can set +`mutate_only_covered_lines` to +`true` in your configuration. The default +value is `false`. + +``` +mutate_only_covered_lines=true +``` + +### Enable debug output (increase verbosity) + +By default, nootnoot "swallows" all the test output etc. so that you get a +nice clean output. + +If you want to see all the detail to aid with debugging, you can set +`debug` to +`true` in your configuration. Note that not +all displayed errors are necessarily bad. In particular test runs of the +mutated code will lead to failing tests. + +``` +debug=true +``` + +### Whitelisting + +You can mark lines like this: + +``` python +some_code_here() # pragma: no mutate +``` + +to stop mutation on those lines. Some cases we've found where you need +to whitelist lines are: + +- The version string on your library. You really shouldn't have a test + for this :P +- Optimizing break instead of continue. The code runs fine when mutating + break to continue, but it's slower. + +### Modifying pytest arguments + +You can add and override pytest arguments: + +``` python +# for CLI args that select or deselect tests, use `pytest_add_cli_args_test_selection` +pytest_add_cli_args_test_selection = ["-m", "not fail", "-k", "test_include"] + +# for other CLI args, use `pytest_add_cli_args` +pytest_add_cli_args = ["-p", "no:some_plugin"] # disable a plugin +pytest_add_cli_args = ["-o", "xfail_strict=False"] # overrides xfail_strict from your normal config + +# if you want to ignore the normal pytest configuration +# you can specify a diferent pytest ini file to be used +pytest_add_cli_args = ["-c", "nootnoot_pytest.ini"] +also_copy = ["nootnoot_pytest.ini"] +``` + +## Example mutations + +- Integer literals are changed by adding 1. So 0 becomes 1, 5 becomes 6, + etc. +- `<` is changed to + `<=` +- break is changed to continue and vice versa + +In general the idea is that the mutations should be as subtle as +possible. See `node_mutation.py` for the +full list and `test_mutation.py` for tests +describing them. + +## Workflow + +This section describes how to work with nootnoot to enhance your test +suite. + +1. Run nootnoot with `nootnoot run`. A full + run is preferred but if you're just getting started you can exit in + the middle and start working with what you have found so far. +2. Show the mutants with `nootnoot browse` +3. Find a mutant you want to work on and write a test to try to kill + it. +4. Press `r` to rerun the mutant and see + if you successfully managed to kill it. + +NootNoot keeps the data of what it has done and the mutants in the +`mutants/` directory.If you want to make +sure you run a full nootnoot run you can delete this directory to start +from scratch. + +## Contributing to NootNoot + +If you wish to contribute to NootNoot, please see our [contributing +guide](CONTRIBUTING.md). diff --git a/README.rst b/README.rst deleted file mode 100644 index e86af6d5..00000000 --- a/README.rst +++ /dev/null @@ -1,252 +0,0 @@ -mutmut - python mutation tester -=============================== - -.. image:: https://github.com/boxed/mutmut/actions/workflows/tests.yml/badge.svg - :target: https://github.com/boxed/mutmut/actions/workflows/tests.yml - -.. image:: https://readthedocs.org/projects/mutmut/badge/?version=latest - :target: https://mutmut.readthedocs.io/en/latest/?badge=latest - :alt: Documentation Status - - -Mutmut is a mutation testing system for Python, with a strong focus on ease -of use. If you don't know what mutation testing is try starting with -`this article `_. - -Some highlight features: - -- Found mutants can be applied on disk with a simple command making it very - easy to work with the results -- Remembers work that has been done, so you can work incrementally -- Knows which tests to execute, speeding up mutation testing -- Interactive terminal based UI -- Parallel and fast execution - -.. image:: browse_screenshot.png - - -If you want to mutate code outside of functions, you can try using mutmut 2, -which has a different execution model than mutmut 3+. - - -Requirements ------------- - -Mutmut must be run on a system with `fork` support. This means that if you want -to run on windows, you must run inside WSL. - - - -Install and run ---------------- - -You can get started with a simple: - -.. code-block:: console - - pip install mutmut - mutmut run - -This will by run pytest on tests in the "tests" or "test" folder and -it will try to figure out where the code to mutate is. - - - -You can stop the mutation run at any time and mutmut will restart where you -left off. It will continue where it left off, and re-test functions that were -modified since last run. - -To work with the results, use `mutmut browse` where you can see the mutants, -retest them when you've updated your tests. - -You can also write a mutant to disk from the `browse` interface, or via -`mutmut apply `. You should **REALLY** have the file you mutate under -source code control and committed before you apply a mutant! - - -If during the installation you get an error for the `libcst` dependency mentioning the lack of a rust compiler on your system, it is because your architecture does not have a prebuilt binary for `libcst` and it requires both `rustc` and `cargo` from the [rust toolchain](https://www.rust-lang.org/tools/install) to be built. This is known for at least the `x86_64-darwin` architecture. - - -Wildcards for testing mutants ------------------------------ - -Unix filename pattern matching style on mutants is supported. Example: - -.. code-block:: console - - mutmut run "my_module*" - mutmut run "my_module.my_function*" - -In the `browse` TUI you can press `f` to retest a function, and `m` to retest -an entire module. - - -Configuration -------------- - -In `setup.cfg` in the root of your project you can configure mutmut if you need to: - -.. code-block:: ini - - [mutmut] - paths_to_mutate=src/ - pytest_add_cli_args_test_selection=tests/ - -If you use `pyproject.toml`, you must specify the paths as array in a `tool.mutmut` section: - -.. code-block:: toml - - [tool.mutmut] - paths_to_mutate = [ "src/" ] - pytest_add_cli_args_test_selection= [ "tests/" ] - -See below for more options for configuring mutmut. - - -"also copy" files -~~~~~~~~~~~~~~~~~ - -To run the full test suite some files are often needed above the tests and the -source. You can configure to copy extra files that you need by adding -directories and files to `also_copy` in your `setup.cfg`: - -.. code-block:: ini - - also_copy= - iommi/snapshots/ - conftest.py - - -Limit stack depth -~~~~~~~~~~~~~~~~~ - -In big code bases some functions are called incidentally by huge swaths of the -codebase, but you really don't want tests that hit those executions to count -for mutation testing purposes. Incidentally tested functions lead to slow -mutation testing as hundreds of tests can be checked for things that should -have clean and fast unit tests, and it leads to bad test suites as any -introduced bug in those base functions will lead to many tests that fail which -are hard to understand how they relate to the function with the change. - -You can configure mutmut to only count a test as being relevant for a function -if the stack depth from the test to the function is below some limit. In your -`setup.cfg` add: - -.. code-block:: ini - - max_stack_depth=8 - -A lower value will increase mutation speed and lead to more localized tests, -but will also lead to more surviving mutants that would otherwise have been -caught. - - -Exclude files from mutation -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -You can exclude files from mutation in `setup.cfg`: - -.. code-block:: - - do_not_mutate= - *__tests.py - - -Enable coverage.py filtering of lines to mutate -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -By default, mutmut will mutate only functions that are called. But, if you would like a finer grained (line-level) -check for coverage, mutmut can use coverage.py to do that. - -If you only want to mutate lines that are called (according to coverage.py), you can set -`mutate_only_covered_lines` to `true` in your configuration. The default value is `false`. - - -.. code-block:: - - mutate_only_covered_lines=true - - -Enable debug output (increase verbosity) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -By default, mutmut "swallows" all the test output etc. so that you get a nice clean output. - -If you want to see all the detail to aid with debugging, you can set `debug` to `true` in your configuration. -Note that not all displayed errors are necessarily bad. In particular test runs of the mutated code will lead -to failing tests. - -.. code-block:: - - debug=true - - -Whitelisting -~~~~~~~~~~~~ - -You can mark lines like this: - -.. code-block:: python - - some_code_here() # pragma: no mutate - -to stop mutation on those lines. Some cases we've found where you need to -whitelist lines are: - -- The version string on your library. You really shouldn't have a test for this :P -- Optimizing break instead of continue. The code runs fine when mutating break - to continue, but it's slower. - - -Modifying pytest arguments -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -You can add and override pytest arguments: - -.. code-block:: python - - # for CLI args that select or deselect tests, use `pytest_add_cli_args_test_selection` - pytest_add_cli_args_test_selection = ["-m", "not fail", "-k", "test_include"] - - # for other CLI args, use `pytest_add_cli_args` - pytest_add_cli_args = ["-p", "no:some_plugin"] # disable a plugin - pytest_add_cli_args = ["-o", "xfail_strict=False"] # overrides xfail_strict from your normal config - - # if you want to ignore the normal pytest configuration - # you can specify a diferent pytest ini file to be used - pytest_add_cli_args = ["-c", "mutmut_pytest.ini"] - also_copy = ["mutmut_pytest.ini"] - - - -Example mutations ------------------ - -- Integer literals are changed by adding 1. So 0 becomes 1, 5 becomes 6, etc. -- `<` is changed to `<=` -- break is changed to continue and vice versa - -In general the idea is that the mutations should be as subtle as possible. -See `node_mutation.py` for the full list and `test_mutation.py` for tests describing them. - - -Workflow --------- - -This section describes how to work with mutmut to enhance your test suite. - -1. Run mutmut with `mutmut run`. A full run is preferred but if you're just - getting started you can exit in the middle and start working with what you - have found so far. -2. Show the mutants with `mutmut browse` -3. Find a mutant you want to work on and write a test to try to kill it. -4. Press `r` to rerun the mutant and see if you successfully managed to kill it. - -Mutmut keeps the data of what it has done and the mutants in the `mutants/` -directory.If you want to make sure you run a full mutmut run you can delete -this directory to start from scratch. - -Contributing to Mutmut ----------------------- - -If you wish to contribute to Mutmut, please see our `contributing guide `_. diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..c1c2887c --- /dev/null +++ b/TODO.md @@ -0,0 +1,245 @@ +# TODO + +## Architecture and boundaries + +* [ ] **Introduce an application-layer run orchestration module** + + * Create `src/nootnoot/core/run_session.py` (name flexible) with a single public entrypoint like `run_mutation_session(...) -> RunReport`. + * Define explicit inputs/outputs (no printing in core; return a report + events). + * Acceptance: + + * CLI `nootnoot run` uses the core entrypoint. + * Core module can be invoked from tests without Click or global state. + +* [ ] **Thin the CLI to argument parsing + rendering** + + * Refactor `src/nootnoot/cli/run.py` to: + + * parse args + * call `core.run_mutation_session` + * render results + choose exit code + * Acceptance: + + * `cli/run.py` contains minimal orchestration logic (no fork/wait loop, no file IO beyond calling core). + * Error messaging goes to stderr; results go to stdout. + +* [ ] **Introduce “ports” for side effects (dependency injection)** + + * Add lightweight interfaces / protocols for: + + * `Clock` (utcnow/time) + * `Env` (get/set env vars for MUTANT_UNDER_TEST) + * `FS` (read/write/atomic replace) + * `ProgressSink` (spinner/progress updates) + * `EventSink` / `Logger` (structured events) + * Acceptance: + + * Core orchestration depends on interfaces, not concrete OS calls. + * Unit tests can run with fakes (no real fork, no real pytest, no disk writes). + +## Execution isolation (reduce heisenbugs) + +* [ ] **Add a subprocess-based pytest runner** + + * Implement `SubprocessPytestRunner` (alongside or instead of in-process `pytest.main` usage). + * Execute `python -m pytest ...` with: + + * `PYTHONPATH` set to prefer `mutants/` + * `MUTANT_UNDER_TEST` set per mutant + * Acceptance: + + * Mutation runs no longer require `sys.path` surgery. + * Debug mode can print the exact subprocess command for repro. + +* [ ] **Make execution strategy configurable** + + * Add config option, e.g. `runner = "subprocess" | "inprocess"` (default to safest). + * Acceptance: + + * Works with existing configs; no behavior regressions for default usage. + +* [ ] **Remove/retire `setup_source_paths()` sys.path mutation** + + * Replace with subprocess environment (`PYTHONPATH`) approach. + * Acceptance: + + * `setup_source_paths()` removed or unused (kept only if needed for legacy runner). + * No direct `sys.path.insert()` needed for the default runner path. + +* [ ] **Rework module unloading during coverage gathering** + + * Avoid manipulating `sys.modules` for correctness. + * Prefer running coverage in subprocess (or isolating the entire “coverage gather” step). + * Acceptance: + + * Coverage gathering is deterministic and does not leak imports into the main process. + +## Concurrency model (supervised workers, parent as single writer) + +* [ ] **Replace fork/wait loop with a supervised worker model** + + * Use `multiprocessing` (or `concurrent.futures`) with: + + * work queue (mutant tasks) + * result queue (exit code, duration, captured output metadata) + * Parent process: + + * schedules tasks + * collects results + * writes `.meta` updates (single writer) + * Acceptance: + + * No per-file mutable PID maps required for correctness. + * Ctrl-C reliably stops workers and leaves meta in a consistent state. + +* [ ] **Implement robust timeouts** + + * Wall-clock timeout enforced by supervisor (terminate worker process). + * Optional Unix CPU time limit as an additional guard (where supported). + * Acceptance: + + * Timeouts produce consistent “timeout” status. + * No stuck workers after interrupt or timeout. + +* [ ] **Capture and manage worker output deterministically** + + * Decide policy: capture stdout/stderr in workers, store in memory only on failure (or store truncated). + * Acceptance: + + * Logs are not interleaved unpredictably across processes. + * CLI output remains readable and stable. + +## Persistence hardening (schema + atomic writes) + +* [x] **Add schema versioning to stats and meta JSON** + + * Add `schema_version: int` to: + + * `mutants/nootnoot-stats.json` + * each `mutants/.meta` + * Add migration logic on read (tolerate unknown keys; warn in debug). + * Acceptance: + + * New fields don’t break old runs. + * Unknown keys do not hard-fail by default. + +* [x] **Implement atomic JSON writes** + + * Write to `*.tmp`, `flush + fsync`, then `os.replace(tmp, path)`. + * Apply to both meta and stats saves. + * Acceptance: + + * Abrupt termination does not corrupt JSON files. + * Readers never observe partially-written JSON. + +* [x] **Centralize persistence logic** + + * Move JSON read/write + migrations into dedicated module, e.g. `nootnoot/persistence.py`. + * Acceptance: + + * `meta.py` and stats handling don’t duplicate serialization rules. + +## Output, observability, and UX contracts + +* [x] **Introduce structured event stream in core** + + * Core emits events like: + + * `session_started`, `mutant_started`, `mutant_finished`, `session_finished`, `error` + * CLI subscribes and renders human output. + * Acceptance: + + * Core does not call `print()`. + * Unit tests can assert on emitted events. + +* [x] **Add `--format json` output mode** + + * JSON output should be stable and machine-readable. + * Ensure no ANSI / spinner output contaminates JSON mode. + * Acceptance: + + * `nootnoot run --format json` prints valid JSON to stdout only. + * Diagnostics go to stderr (or are suppressed per contract). + +* [x] **Fix stdout/stderr separation across all commands** + + * Results: stdout + * Diagnostics/progress/errors: stderr + * Acceptance: + + * Running in pipelines works correctly (`nootnoot ... | jq`). + +* [ ] **Make progress reporting TTY-aware and rate-limited** + + * Replace current spinner/print throttling with `ProgressSink`. + * Disable progress automatically in non-TTY or JSON mode. + * Acceptance: + + * No flicker spam in CI logs. + * Progress updates do not interfere with result output. + +## Testing strategy (locks in correctness) + +* [ ] **Unit tests for new core orchestration** + + * Use fakes for runner/fs/clock/env. + * Cover: + + * scheduling order + * timeout handling + * error propagation + * event emission + * Acceptance: + + * Tests do not require pytest invocation, fork, or real filesystem. + +* [ ] **Component tests using a tiny fixture project** + + * Run end-to-end in a temp directory: + + * generates mutants + * writes meta/stats + * produces stable summary + * Acceptance: + + * Deterministic results across runs. + +* [ ] **System tests for CLI** + + * Execute `python -m nootnoot ...` as subprocess against fixture project. + * Assert: + + * exit codes + * key output lines (human) + * valid JSON (machine) + * Acceptance: + + * CLI contract is stable and versionable. + +* [ ] **Contract tests for JSON output schema** + + * Snapshot tests for `--format json` output. + * Acceptance: + + * Schema changes require intentional updates. + +## Cleanup and consolidation + +* [ ] **Remove duplicated helpers and consolidate constants** + + * Deduplicate `collected_test_names()` (currently exists in multiple modules). + * Replace magic codes/strings with constants/enums where appropriate. + * Acceptance: + + * Single source of truth for statuses/exit codes. + +* [ ] **Document runner modes and output contracts** + + * Update README/docs to describe: + + * subprocess vs in-process runner + * JSON format stability expectations + * stderr/stdout rules + * Acceptance: + + * Users know how to integrate nootnoot into CI reliably. diff --git a/cached-results-plan.txt b/cached-results-plan.txt deleted file mode 100644 index e70d5be2..00000000 --- a/cached-results-plan.txt +++ /dev/null @@ -1,4 +0,0 @@ -Mutmut cached results plan: - -Unanswered question: - When do we update the cache? It must be safe so that you can quit mutmut at any time and the cache won't be broken. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 00000000..a80de5bd --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1 @@ +--8<-- "ARCHITECTURE.md" diff --git a/docs/AUTHORS.md b/docs/AUTHORS.md new file mode 100644 index 00000000..4dd84414 --- /dev/null +++ b/docs/AUTHORS.md @@ -0,0 +1 @@ +--8<-- "AUTHORS.md" diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 00000000..ea38c9bf --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1 @@ +--8<-- "CONTRIBUTING.md" diff --git a/docs/HISTORY.md b/docs/HISTORY.md new file mode 100644 index 00000000..786b75d5 --- /dev/null +++ b/docs/HISTORY.md @@ -0,0 +1 @@ +--8<-- "CHANGELOG.md" diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 961a29d4..00000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -SPHINXPROJ = mutmut -SOURCEDIR = . -BUILDDIR = _build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -t docs diff --git a/docs/_templates/ghbuttons.html b/docs/_templates/ghbuttons.html deleted file mode 100755 index 1d69faa6..00000000 --- a/docs/_templates/ghbuttons.html +++ /dev/null @@ -1,4 +0,0 @@ -

Github

- -

diff --git a/docs/_templates/sidebarlogo.html b/docs/_templates/sidebarlogo.html deleted file mode 100755 index a07ba8b1..00000000 --- a/docs/_templates/sidebarlogo.html +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/docs/_themes/flask/LICENSE b/docs/_themes/flask/LICENSE deleted file mode 100755 index 8daab7ee..00000000 --- a/docs/_themes/flask/LICENSE +++ /dev/null @@ -1,37 +0,0 @@ -Copyright (c) 2010 by Armin Ronacher. - -Some rights reserved. - -Redistribution and use in source and binary forms of the theme, with or -without modification, are permitted provided that the following conditions -are met: - -* Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following - disclaimer in the documentation and/or other materials provided - with the distribution. - -* The names of the contributors may not be used to endorse or - promote products derived from this software without specific - prior written permission. - -We kindly ask you to only use these themes in an unmodified manner just -for Flask and Flask-related products, not for unrelated projects. If you -like the visual style and want to use it for your own projects, please -consider making some larger changes to the themes (such as changing -font faces, sizes, colors or margins). - -THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE -LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/_themes/flask/layout.html b/docs/_themes/flask/layout.html deleted file mode 100755 index bcd9ddeb..00000000 --- a/docs/_themes/flask/layout.html +++ /dev/null @@ -1,27 +0,0 @@ -{%- extends "basic/layout.html" %} -{%- block extrahead %} - {{ super() }} - {% if theme_touch_icon %} - - {% endif %} - - - Fork me - -{% endblock %} -{%- block relbar2 %}{% endblock %} -{% block header %} - {{ super() }} - {% if pagename == 'index' %} -
- {% endif %} -{% endblock %} -{%- block footer %} - - {% if pagename == 'index' %} -
- {% endif %} -{%- endblock %} diff --git a/docs/_themes/flask/relations.html b/docs/_themes/flask/relations.html deleted file mode 100755 index 3bbcde85..00000000 --- a/docs/_themes/flask/relations.html +++ /dev/null @@ -1,19 +0,0 @@ -

Related Topics

- diff --git a/docs/_themes/flask/static/flasky.css_t b/docs/_themes/flask/static/flasky.css_t deleted file mode 100755 index 79ab4787..00000000 --- a/docs/_themes/flask/static/flasky.css_t +++ /dev/null @@ -1,394 +0,0 @@ -/* - * flasky.css_t - * ~~~~~~~~~~~~ - * - * :copyright: Copyright 2010 by Armin Ronacher. - * :license: Flask Design License, see LICENSE for details. - */ - -{% set page_width = '940px' %} -{% set sidebar_width = '220px' %} - -@import url("basic.css"); - -/* -- page layout ----------------------------------------------------------- */ - -body { - font-family: 'Georgia', serif; - font-size: 17px; - background-color: white; - color: #000; - margin: 0; - padding: 0; -} - -div.document { - width: {{ page_width }}; - margin: 30px auto 0 auto; -} - -div.documentwrapper { - float: left; - width: 100%; -} - -div.bodywrapper { - margin: 0 0 0 {{ sidebar_width }}; -} - -div.sphinxsidebar { - width: {{ sidebar_width }}; -} - -hr { - border: 1px solid #B1B4B6; -} - -div.body { - background-color: #ffffff; - color: #3E4349; - padding: 0 30px 0 30px; -} - -img.floatingflask { - padding: 0 0 10px 10px; - float: right; -} - -div.footer { - width: {{ page_width }}; - margin: 20px auto 30px auto; - font-size: 14px; - color: #888; - text-align: right; -} - -div.footer a { - color: #888; -} - -div.related { - display: none; -} - -div.sphinxsidebar a { - color: #444; - text-decoration: none; - border-bottom: 1px dotted #999; -} - -div.sphinxsidebar a:hover { - border-bottom: 1px solid #999; -} - -div.sphinxsidebar { - font-size: 14px; - line-height: 1.5; -} - -div.sphinxsidebarwrapper { - padding: 18px 10px; -} - -div.sphinxsidebarwrapper p.logo { - padding: 0 0 20px 0; - margin: 0; - text-align: center; -} - -div.sphinxsidebar h3, -div.sphinxsidebar h4 { - font-family: 'Garamond', 'Georgia', serif; - color: #444; - font-size: 24px; - font-weight: normal; - margin: 0 0 5px 0; - padding: 0; -} - -div.sphinxsidebar h4 { - font-size: 20px; -} - -div.sphinxsidebar h3 a { - color: #444; -} - -div.sphinxsidebar p.logo a, -div.sphinxsidebar h3 a, -div.sphinxsidebar p.logo a:hover, -div.sphinxsidebar h3 a:hover { - border: none; -} - -div.sphinxsidebar p { - color: #555; - margin: 10px 0; -} - -div.sphinxsidebar ul { - margin: 10px 0; - padding: 0; - color: #000; -} - -div.sphinxsidebar input { - border: 1px solid #ccc; - font-family: 'Georgia', serif; - font-size: 1em; -} - -/* -- body styles ----------------------------------------------------------- */ - -a { - color: #004B6B; - text-decoration: underline; -} - -a:hover { - color: #6D4100; - text-decoration: underline; -} - -div.body h1, -div.body h2, -div.body h3, -div.body h4, -div.body h5, -div.body h6 { - font-family: 'Garamond', 'Georgia', serif; - font-weight: normal; - margin: 30px 0px 10px 0px; - padding: 0; -} - -{% if theme_index_logo %} -div.indexwrapper h1 { - text-indent: -999999px; - background: url({{ theme_index_logo }}) no-repeat center center; - height: {{ theme_index_logo_height }}; -} -{% endif %} - -div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; } -div.body h2 { font-size: 180%; } -div.body h3 { font-size: 150%; } -div.body h4 { font-size: 130%; } -div.body h5 { font-size: 100%; } -div.body h6 { font-size: 100%; } - -a.headerlink { - color: #ddd; - padding: 0 4px; - text-decoration: none; -} - -a.headerlink:hover { - color: #444; -} - -div.body p, div.body dd, div.body li { - line-height: 1.4em; -} - -div.admonition { - background: #fafafa; - margin: 20px -30px; - padding: 10px 30px; - border-top: 1px solid #ccc; - border-bottom: 1px solid #ccc; -} - -div.admonition tt.xref, div.admonition a tt { - border-bottom: 1px solid #fafafa; -} - -dd div.admonition { - margin-left: -60px; - padding-left: 60px; -} - -div.admonition p.admonition-title { - font-family: 'Garamond', 'Georgia', serif; - font-weight: normal; - font-size: 24px; - margin: 0 0 10px 0; - padding: 0; - line-height: 1; -} - -div.admonition p.last { - margin-bottom: 0; -} - -div.highlight { - background-color: white; -} - -dt:target, .highlight { - background: #FAF3E8; -} - -div.note { - background-color: #eee; - border: 1px solid #ccc; -} - -div.seealso { - background-color: #ffc; - border: 1px solid #ff6; -} - -div.topic { - background-color: #eee; -} - -p.admonition-title { - display: inline; -} - -p.admonition-title:after { - content: ":"; -} - -pre, tt { - font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; - font-size: 0.9em; -} - -img.screenshot { -} - -tt.descname, tt.descclassname { - font-size: 0.95em; -} - -tt.descname { - padding-right: 0.08em; -} - -img.screenshot { - -moz-box-shadow: 2px 2px 4px #eee; - -webkit-box-shadow: 2px 2px 4px #eee; - box-shadow: 2px 2px 4px #eee; -} - -table.docutils { - border: 1px solid #888; - -moz-box-shadow: 2px 2px 4px #eee; - -webkit-box-shadow: 2px 2px 4px #eee; - box-shadow: 2px 2px 4px #eee; -} - -table.docutils td, table.docutils th { - border: 1px solid #888; - padding: 0.25em 0.7em; -} - -table.field-list, table.footnote { - border: none; - -moz-box-shadow: none; - -webkit-box-shadow: none; - box-shadow: none; -} - -table.footnote { - margin: 15px 0; - width: 100%; - border: 1px solid #eee; - background: #fdfdfd; - font-size: 0.9em; -} - -table.footnote + table.footnote { - margin-top: -15px; - border-top: none; -} - -table.field-list th { - padding: 0 0.8em 0 0; -} - -table.field-list td { - padding: 0; -} - -table.footnote td.label { - width: 0px; - padding: 0.3em 0 0.3em 0.5em; -} - -table.footnote td { - padding: 0.3em 0.5em; -} - -dl { - margin: 0; - padding: 0; -} - -dl dd { - margin-left: 30px; -} - -blockquote { - margin: 0 0 0 30px; - padding: 0; -} - -ul, ol { - margin: 10px 0 10px 30px; - padding: 0; -} - -pre { - background: #eee; - padding: 7px 30px; - margin: 15px -30px; - line-height: 1.3em; -} - -dl pre, blockquote pre, li pre { - margin-left: -60px; - padding-left: 60px; -} - -dl dl pre { - margin-left: -90px; - padding-left: 90px; -} - -tt { - background-color: #ecf0f3; - color: #222; - /* padding: 1px 2px; */ -} - -tt.xref, a tt { - background-color: #FBFBFB; - border-bottom: 1px solid white; -} - -a.reference { - text-decoration: none; - border-bottom: 1px dotted #004B6B; -} - -a.reference:hover { - border-bottom: 1px solid #6D4100; -} - -a.footnote-reference { - text-decoration: none; - font-size: 0.7em; - vertical-align: top; - border-bottom: 1px dotted #004B6B; -} - -a.footnote-reference:hover { - border-bottom: 1px solid #6D4100; -} - -a:hover tt { - background: #EEE; -} diff --git a/docs/_themes/flask/static/small_flask.css b/docs/_themes/flask/static/small_flask.css deleted file mode 100755 index 1c6df309..00000000 --- a/docs/_themes/flask/static/small_flask.css +++ /dev/null @@ -1,70 +0,0 @@ -/* - * small_flask.css_t - * ~~~~~~~~~~~~~~~~~ - * - * :copyright: Copyright 2010 by Armin Ronacher. - * :license: Flask Design License, see LICENSE for details. - */ - -body { - margin: 0; - padding: 20px 30px; -} - -div.documentwrapper { - float: none; - background: white; -} - -div.sphinxsidebar { - display: block; - float: none; - width: 102.5%; - margin: 50px -30px -20px -30px; - padding: 10px 20px; - background: #333; - color: white; -} - -div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p, -div.sphinxsidebar h3 a { - color: white; -} - -div.sphinxsidebar a { - color: #aaa; -} - -div.sphinxsidebar p.logo { - display: none; -} - -div.document { - width: 100%; - margin: 0; -} - -div.related { - display: block; - margin: 0; - padding: 10px 0 20px 0; -} - -div.related ul, -div.related ul li { - margin: 0; - padding: 0; -} - -div.footer { - display: none; -} - -div.bodywrapper { - margin: 0; -} - -div.body { - min-height: 0; - padding: 0; -} diff --git a/docs/_themes/flask/theme.conf b/docs/_themes/flask/theme.conf deleted file mode 100755 index 1d5657f2..00000000 --- a/docs/_themes/flask/theme.conf +++ /dev/null @@ -1,9 +0,0 @@ -[theme] -inherit = basic -stylesheet = flasky.css -pygments_style = flask_theme_support.FlaskyStyle - -[options] -index_logo = -index_logo_height = 120px -touch_icon = diff --git a/docs/_themes/flask_theme_support.py b/docs/_themes/flask_theme_support.py deleted file mode 100755 index d3e33c06..00000000 --- a/docs/_themes/flask_theme_support.py +++ /dev/null @@ -1,125 +0,0 @@ -""" -Copyright (c) 2010 by Armin Ronacher. - -Some rights reserved. - -Redistribution and use in source and binary forms of the theme, with or -without modification, are permitted provided that the following conditions -are met: - -* Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following - disclaimer in the documentation and/or other materials provided - with the distribution. - -* The names of the contributors may not be used to endorse or - promote products derived from this software without specific - prior written permission. - -We kindly ask you to only use these themes in an unmodified manner just -for Flask and Flask-related products, not for unrelated projects. If you -like the visual style and want to use it for your own projects, please -consider making some larger changes to the themes (such as changing -font faces, sizes, colors or margins). - -THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE -LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. -""" -# flasky extensions. flasky pygments style based on tango style -from pygments.style import Style -from pygments.token import Keyword, Name, Comment, String, Error, \ - Number, Operator, Generic, Whitespace, Punctuation, Other, Literal - - -class FlaskyStyle(Style): - background_color = "#f8f8f8" - default_style = "" - - styles = { - # No corresponding class for the following: - #Text: "", # class: '' - Whitespace: "underline #f8f8f8", # class: 'w' - Error: "#a40000 border:#ef2929", # class: 'err' - Other: "#000000", # class 'x' - - Comment: "italic #8f5902", # class: 'c' - Comment.Preproc: "noitalic", # class: 'cp' - - Keyword: "bold #004461", # class: 'k' - Keyword.Constant: "bold #004461", # class: 'kc' - Keyword.Declaration: "bold #004461", # class: 'kd' - Keyword.Namespace: "bold #004461", # class: 'kn' - Keyword.Pseudo: "bold #004461", # class: 'kp' - Keyword.Reserved: "bold #004461", # class: 'kr' - Keyword.Type: "bold #004461", # class: 'kt' - - Operator: "#582800", # class: 'o' - Operator.Word: "bold #004461", # class: 'ow' - like keywords - - Punctuation: "bold #000000", # class: 'p' - - # because special names such as Name.Class, Name.Function, etc. - # are not recognized as such later in the parsing, we choose them - # to look the same as ordinary variables. - Name: "#000000", # class: 'n' - Name.Attribute: "#c4a000", # class: 'na' - to be revised - Name.Builtin: "#004461", # class: 'nb' - Name.Builtin.Pseudo: "#3465a4", # class: 'bp' - Name.Class: "#000000", # class: 'nc' - to be revised - Name.Constant: "#000000", # class: 'no' - to be revised - Name.Decorator: "#888", # class: 'nd' - to be revised - Name.Entity: "#ce5c00", # class: 'ni' - Name.Exception: "bold #cc0000", # class: 'ne' - Name.Function: "#000000", # class: 'nf' - Name.Property: "#000000", # class: 'py' - Name.Label: "#f57900", # class: 'nl' - Name.Namespace: "#000000", # class: 'nn' - to be revised - Name.Other: "#000000", # class: 'nx' - Name.Tag: "bold #004461", # class: 'nt' - like a keyword - Name.Variable: "#000000", # class: 'nv' - to be revised - Name.Variable.Class: "#000000", # class: 'vc' - to be revised - Name.Variable.Global: "#000000", # class: 'vg' - to be revised - Name.Variable.Instance: "#000000", # class: 'vi' - to be revised - - Number: "#990000", # class: 'm' - - Literal: "#000000", # class: 'l' - Literal.Date: "#000000", # class: 'ld' - - String: "#4e9a06", # class: 's' - String.Backtick: "#4e9a06", # class: 'sb' - String.Char: "#4e9a06", # class: 'sc' - String.Doc: "italic #8f5902", # class: 'sd' - like a comment - String.Double: "#4e9a06", # class: 's2' - String.Escape: "#4e9a06", # class: 'se' - String.Heredoc: "#4e9a06", # class: 'sh' - String.Interpol: "#4e9a06", # class: 'si' - String.Other: "#4e9a06", # class: 'sx' - String.Regex: "#4e9a06", # class: 'sr' - String.Single: "#4e9a06", # class: 's1' - String.Symbol: "#4e9a06", # class: 'ss' - - Generic: "#000000", # class: 'g' - Generic.Deleted: "#a40000", # class: 'gd' - Generic.Emph: "italic #000000", # class: 'ge' - Generic.Error: "#ef2929", # class: 'gr' - Generic.Heading: "bold #000080", # class: 'gh' - Generic.Inserted: "#00A000", # class: 'gi' - Generic.Output: "#888", # class: 'go' - Generic.Prompt: "#745334", # class: 'gp' - Generic.Strong: "bold #000000", # class: 'gs' - Generic.Subheading: "bold #800080", # class: 'gu' - Generic.Traceback: "bold #a40000", # class: 'gt' - } diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 72c5743f..00000000 --- a/docs/conf.py +++ /dev/null @@ -1,166 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Configuration file for the Sphinx documentation builder. -# -# This file does only contain a selection of the most common options. For a -# full list see the documentation: -# http://www.sphinx-doc.org/en/master/config - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) - - -# -- Project information ----------------------------------------------------- - -project = u'mutmut' -copyright = u'2018, Anders Hovmöller' -author = u'Anders Hovmöller' - -# The short X.Y version -version = u'' -# The full version, including alpha/beta/rc tags -release = u'' - - -# -- General configuration --------------------------------------------------- - -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - 'sphinx.ext.githubpages', -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = '.rst' - -# The master toctree document. -master_doc = 'index' - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = 'en' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path . -exclude_patterns = [u'_build', 'Thumbs.db', '.DS_Store'] - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - - -# -- Options for HTML output ------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = 'alabaster' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# -# html_theme_options = {} - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -# html_static_path = ['_static'] - -# Custom sidebar templates, must be a dictionary that maps document names -# to template names. -# -# The default sidebars (for documents that don't match any pattern) are -# defined by theme itself. Builtin themes are using these templates by -# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', -# 'searchbox.html']``. -# -html_sidebars = { - '**': [ - 'sidebarlogo.html', - 'localtoc.html', - 'ghbuttons.html', - 'searchbox.html' - ] -} - - -# -- Options for HTMLHelp output --------------------------------------------- - -# Output file base name for HTML help builder. -htmlhelp_basename = 'mutmutdoc' - - -# -- Options for LaTeX output ------------------------------------------------ - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, 'mutmut.tex', u'mutmut Documentation', - u'Anders Hovmöller', 'manual'), -] - - -# -- Options for manual page output ------------------------------------------ - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'mutmut', u'mutmut Documentation', - [author], 1) -] - - -# -- Options for Texinfo output ---------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - (master_doc, 'mutmut', u'mutmut Documentation', - author, 'mutmut', 'One line description of project.', - 'Miscellaneous'), -] - - -# -- Extension configuration ------------------------------------------------- diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..9c8cb68d --- /dev/null +++ b/docs/index.md @@ -0,0 +1,7 @@ +--8<-- "README.md" + +## Resources + +- [Source Code on GitHub](https://github.com/boxed/nootnoot) +- [CI status](https://github.com/boxed/nootnoot/actions/workflows/tests.yml) +- [Python Package Index](https://pypi.org/project/nootnoot/) diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 1f474780..00000000 --- a/docs/index.rst +++ /dev/null @@ -1,10 +0,0 @@ -.. include:: ../README.rst - -.. _toc: - -Resources ---------- - -- `Source Code on Github `_ -- `Travis Testing `_ -- `Python Package Index `_ diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 50eeaaad..00000000 --- a/docs/make.bat +++ /dev/null @@ -1,36 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=. -set BUILDDIR=_build -set SPHINXPROJ=mutmut - -if "%1" == "" goto help - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% - -:end -popd diff --git a/e2e_projects/config/README.md b/e2e_projects/config/README.md index 1d59f03c..37efd16b 100644 --- a/e2e_projects/config/README.md +++ b/e2e_projects/config/README.md @@ -1 +1 @@ -This project uses most/all of the mutmut configuration in pyproject.toml. \ No newline at end of file +This project uses most/all of the nootnoot configuration in pyproject.toml. \ No newline at end of file diff --git a/e2e_projects/config/pyproject.toml b/e2e_projects/config/pyproject.toml index 10e196cc..73483ed1 100644 --- a/e2e_projects/config/pyproject.toml +++ b/e2e_projects/config/pyproject.toml @@ -1,39 +1,41 @@ [project] -name = "config" -version = "0.1.0" -description = "Add your description here" -readme = "README.md" -authors = [] -requires-python = ">=3.10" -dependencies = [] + name = "config" + version = "0.1.0" + description = "Add your description here" + readme = "README.md" + authors = [] + requires-python = ">=3.10" + dependencies = [] [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" + requires = ["hatchling"] + build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -include = [ - "/config_pkg", - "/tests", -] + include = [ + "/config_pkg", + "/tests", + ] [dependency-groups] -dev = [ + dev = [ "pytest>=8.3.5", -] + ] -[tool.mutmut] -debug = true -paths_to_mutate = [ "config_pkg/" ] -do_not_mutate = [ "*ignore*" ] -also_copy = [ "data" ] -max_stack_depth=8 # Includes frames by mutmut, see https://github.com/boxed/mutmut/issues/378 -tests_dir = [ "tests/main/" ] -# verify that we can override options with pytest_add_cli_args -pytest_add_cli_args = ["-o", "xfail_strict=False"] -# verify test exclusion (-m 'not fail') and test inclusion (-k=test_include) -pytest_add_cli_args_test_selection = [ "-m", "not fail", "-k=test_include"] +[tool.nootnoot] + debug = true + paths_to_mutate = ["config_pkg/"] + do_not_mutate = ["*ignore*"] + also_copy = ["data"] + max_stack_depth = 8 # Includes frames by nootnoot, see https://github.com/boxed/nootnoot/issues/378 + tests_dir = ["tests/main/"] + # verify that we can override options with pytest_add_cli_args + pytest_add_cli_args = ["-o", "xfail_strict=False"] + # verify test exclusion (-m 'not fail') and test inclusion (-k=test_include) + pytest_add_cli_args_test_selection = ["-m", "not fail", "-k=test_include"] [tool.pytest.ini_options] -xfail_strict = true -markers = [ "fail: tests that should be ignored with mutmut" ] \ No newline at end of file + xfail_strict = true + markers = ["fail: tests that should be ignored with nootnoot"] + asyncio_default_fixture_loop_scope = "function" + diff --git a/e2e_projects/config/tests/main/test_main.py b/e2e_projects/config/tests/main/test_main.py index c632e4bc..195f163c 100644 --- a/e2e_projects/config/tests/main/test_main.py +++ b/e2e_projects/config/tests/main/test_main.py @@ -16,7 +16,7 @@ def test_include_non_mutated_function(): def test_include_max_stack_depth(): # This test should only cover functions up to some depth - # For more context, see https://github.com/boxed/mutmut/issues/378 + # For more context, see https://github.com/boxed/nootnoot/issues/378 assert call_depth_two() == 2 def test_include_data_exists(): diff --git a/e2e_projects/mutate_only_covered_lines/README.md b/e2e_projects/mutate_only_covered_lines/README.md index f32bddc3..e40a32d2 100644 --- a/e2e_projects/mutate_only_covered_lines/README.md +++ b/e2e_projects/mutate_only_covered_lines/README.md @@ -1 +1 @@ -This project will be E2E tested. It is used to test the mutate_only_covered_lines feature of mutmut. +This project will be E2E tested. It is used to test the mutate_only_covered_lines feature of nootnoot. diff --git a/e2e_projects/mutate_only_covered_lines/pyproject.toml b/e2e_projects/mutate_only_covered_lines/pyproject.toml index 4903c6a4..da812756 100644 --- a/e2e_projects/mutate_only_covered_lines/pyproject.toml +++ b/e2e_projects/mutate_only_covered_lines/pyproject.toml @@ -16,7 +16,7 @@ dev = [ "pytest>=8.3.5", ] -[tool.mutmut] +[tool.nootnoot] debug = true mutate_only_covered_lines = true tests_dir = [ "tests/main/" ] diff --git a/e2e_projects/my_lib/pyproject.toml b/e2e_projects/my_lib/pyproject.toml index bcc4ccf0..4a438abd 100644 --- a/e2e_projects/my_lib/pyproject.toml +++ b/e2e_projects/my_lib/pyproject.toml @@ -16,7 +16,7 @@ dev = [ "pytest>=8.3.5", ] -[tool.mutmut] +[tool.nootnoot] debug = true [tool.pytest] diff --git a/import-linter.toml b/import-linter.toml new file mode 100644 index 00000000..bfb986c9 --- /dev/null +++ b/import-linter.toml @@ -0,0 +1,63 @@ +[tool.importlinter] + root_package = "nootnoot" + include_external_packages = true + exclude_type_checking_imports = true + + # Layering: cli -> app -> core + [[tool.importlinter.contracts]] + name = "Enforce nootnoot layering (cli -> app -> core)" + type = "layers" + layers = [ + "nootnoot.cli", + "nootnoot.app", + "nootnoot.core", + ] + + # Core must be pure (no app/cli, no heavy side-effectful deps) + [[tool.importlinter.contracts]] + name = "Core must be pure" + type = "forbidden" + source_modules = ["nootnoot.core"] + forbidden_modules = [ + "nootnoot.app", + "nootnoot.cli", + "os", + "multiprocessing", + "pytest", + "coverage", + ] + + # Limit intra-app coupling (protect heavyweight mutation module) + [[tool.importlinter.contracts]] + name = "Limit intra-app coupling" + type = "forbidden" + source_modules = [ + "nootnoot.app.code_coverage", + "nootnoot.app.meta", + "nootnoot.app.persistence", + "nootnoot.app.reporting", + "nootnoot.app.runners", + ] + forbidden_modules = [ + "nootnoot.app.mutation", + ] + + # Prevent sensitive app cycles (state must remain low-level) + [[tool.importlinter.contracts]] + name = "No cycles between app state and runners" + type = "forbidden" + source_modules = ["nootnoot.app.state"] + forbidden_modules = ["nootnoot.app.runners"] + + # CLI isolation: only entrypoints may import cli + [[tool.importlinter.contracts]] + name = "No app->cli dependency" + type = "forbidden" + source_modules = ["nootnoot.app"] + forbidden_modules = ["nootnoot.cli"] + + [[tool.importlinter.contracts]] + name = "No core->cli dependency" + type = "forbidden" + source_modules = ["nootnoot.core"] + forbidden_modules = ["nootnoot.cli"] diff --git a/justfile b/justfile new file mode 100644 index 00000000..cd9ad5e4 --- /dev/null +++ b/justfile @@ -0,0 +1,595 @@ +# ====================================================================== +# Global shell + environment +# ====================================================================== + +set shell := ["bash", "-euo", "pipefail", "-c"] +set dotenv-load := true +set export := true + +# ---------------------------------------------------------------------- +# Config (overridable via env/.env) +# ---------------------------------------------------------------------- + +PYTHON_PACKAGE := env("PYTHON_PACKAGE", "borh") +PY_TESTPATH := env("PY_TESTPATH", "tests") +PY_SRC := env("PY_SRC", "src") +VERBOSE := env("VERBOSE", "0") + +# ---------------------------------------------------------------------- +# Tool wrappers +# ---------------------------------------------------------------------- + +UV := "uv" +RUFF := justfile_directory() + "/.venv/bin/ruff" +PYTEST := justfile_directory() + "/.venv/bin/pytest" +TY := justfile_directory() + "/.venv/bin/ty" +SHOWCOV := justfile_directory() + "/.venv/bin/showcov" +NOOTNOOT := justfile_directory() + "/.venv/bin/nootnoot" +MKDOCS := justfile_directory() + "/.venv/bin/mkdocs" +WILY := justfile_directory() + "/.venv/bin/wily" +WILY_CACHE := justfile_directory() + "/.wily" +WILY_CONFIG := justfile_directory() + "/wily.cfg" +VULTURE := justfile_directory() + "/.venv/bin/vulture" +RADON := justfile_directory() + "/.venv/bin/radon" +JSCPD := "npx --yes jscpd@4.0" +DIFF_COVER := justfile_directory() + "/.venv/bin/diff-cover" +IMPORTLINTER := justfile_directory() + "/.venv/bin/lint-imports" +IMPORTLINTER_CONFIG := justfile_directory() + "/import-linter.toml" + +# ====================================================================== +# Meta / Defaults +# ====================================================================== + +[private] +default: help + +# List available recipes; also the default entry point +help: + @just --list --unsorted --list-prefix " " + +# Print runtime configuration (paths + tool binaries) +env: + @echo "PYTHON_PACKAGE={{PYTHON_PACKAGE}}" + @echo "PY_TESTPATH={{PY_TESTPATH}}" + @echo "PY_SRC={{PY_SRC}}" + @echo "UV={{UV}}" + @echo "RUFF={{RUFF}}" + @echo "PYTEST={{PYTEST}}" + @echo "TY={{TY}}" + @echo "SHOWCOV={{SHOWCOV}}" + @echo "NOOTNOOT={{NOOTNOOT}}" + @echo "MKDOCS={{MKDOCS}}" + @{{UV}} --version || true + @{{PYTEST}} --version || true + @{{RUFF}} --version || true + @echo "WILY={{WILY}}" + @echo "WILY_CACHE={{WILY_CACHE}}" + @echo "WILY_CONFIG={{WILY_CONFIG}}" + @echo "VULTURE={{VULTURE}}" + @echo "RADON={{RADON}}" + @echo "JSCPD={{JSCPD}}" + @echo "DIFF_COVER={{DIFF_COVER}}" + +# ====================================================================== +# Bootstrap +# ====================================================================== + +# Bootstrap: refresh .venv via `uv sync` +setup: + {{UV}} sync + +# ====================================================================== +# Code quality: lint / format / type-check +# ====================================================================== + +# Code Quality: Lint with `ruff check` and auto-fix where possible +lint: + {{RUFF}} check --fix {{PY_SRC}} {{PY_TESTPATH}} || true + +# Code Quality: Check for linting violations with `ruff check` without modifying files +lint-no-fix: + {{RUFF}} check --no-fix {{PY_SRC}} {{PY_TESTPATH}} + +# Code Quality: Lint import architecture (Import Linter) +lint-imports: + #!/usr/bin/env bash + if [ ! -x {{IMPORTLINTER}} ]; then + echo "[lint-imports] ERROR: lint-imports not found ({{IMPORTLINTER}}); install import-linter dev dep and run 'just setup'" + exit 1 + fi + + set +e + output="$({{IMPORTLINTER}} --verbose --config {{IMPORTLINTER_CONFIG}} 2>&1)" + status=$? + set -e + + if [ "$status" -ne 0 ]; then + echo "[lint-imports] FAILED" + echo + echo "$output" + exit "$status" + else + echo "[lint-imports] no import-linter contract violations detected." + fi + +# Code Quality: Format with `ruff format` and auto-fix where possible +format: + {{RUFF}} format {{PY_SRC}} {{PY_TESTPATH}} || true + +# Code Quality: Check for formatting violations with `ruff format` without modifying files +format-no-fix: + {{RUFF}} format --check {{PY_SRC}} {{PY_TESTPATH}} + +# Code Quality: Typecheck with `ty` (if available) +typecheck: + #!/usr/bin/env bash + if [ -x {{TY}} ]; then + {{TY}} check {{PY_SRC}} {{PY_TESTPATH}} + else + echo "[typecheck] skipping: ty not found ({{TY}})" + fi + +# Code Quality: dead-code scan +dead-code: + {{VULTURE}} {{PY_SRC}} {{PY_TESTPATH}} || true + +# Code Quality: complexity report +complexity: + {{RADON}} cc -s -a {{PY_SRC}} + +# Code Quality: raw metrics (optional) +complexity-raw: + {{RADON}} raw {{PY_SRC}} + +# Code Quality: strict complexity check (fail on high-complexity blocks) +complexity-strict MIN_COMPLEXITY="11": + #!/usr/bin/env bash + echo "[complexity-strict] Failing if any block has cyclomatic complexity >= ${MIN_COMPLEXITY}" + output="$({{RADON}} cc -s -n {{MIN_COMPLEXITY}} {{PY_SRC}} || true)" + if [ -n "$output" ]; then + echo "[complexity-strict] Found blocks with complexity >= ${MIN_COMPLEXITY}:" + echo "$output" + exit 1 + fi + echo "[complexity-strict] All blocks are below complexity ${MIN_COMPLEXITY}." + +# Code Quality: duplication detection +dup: + {{JSCPD}} --pattern "{{PY_SRC}}/*/*.py" --pattern "{{PY_SRC}}/*/*/*.py" --pattern "{{PY_SRC}}/*/*/*/*.py" --pattern "{{PY_TESTPATH}}/*/*.py" --pattern "{{PY_TESTPATH}}/*/*/*.py" --pattern "{{PY_TESTPATH}}/*/*/*/*.py" --reporters console + + +# ====================================================================== +# Security / supply chain +# ====================================================================== + +# Security: Secret scan with trufflehog (report-only; does not fail if tool missing) +sec-secrets: + #!/usr/bin/env bash + if command -v trufflehog >/dev/null 2>&1; then + tmp_file=$(mktemp) + printf ".venv\nbuild\ndist\n" > "$tmp_file" + trufflehog filesystem . --exclude-paths "$tmp_file" + rm -f "$tmp_file" + else + echo "[sec-secrets] skipping: trufflehog not found on PATH" + fi + +# Security: Dependency scan with pip-audit +sec-deps: + #!/usr/bin/env bash + if [ -x .venv/bin/pip-audit ]; then + .venv/bin/pip-audit + else + echo "[sec-deps] ERROR: .venv/bin/pip-audit not found; run 'just setup' to install dev deps" + exit 1 + fi + +# Security: Check external tools (presence + minimum versions) +sec-tools: + #!/usr/bin/env bash + if [ -x .venv/bin/python ] && [ -f scripts/check_tools.py ]; then + .venv/bin/python scripts/check_tools.py + else + echo "[sec-tools] ERROR: scripts/check_tools.py missing" + exit 1 + fi + + +# ====================================================================== +# Testing +# ====================================================================== + +# Testing: Run full test suite +test: + {{PYTEST}} {{PY_TESTPATH}} || true + +# Testing: Run full test suite and fail if any test fails +test-strict: + {{PYTEST}} {{PY_TESTPATH}} + +# Testing: Marker-driven test runner with graceful "no tests" handling +test-marker MARKER: + #!/usr/bin/env bash + set +e + {{PYTEST}} {{PY_TESTPATH}} -m "{{MARKER}}" + status=$? + set -e + if [ "$status" -eq 5 ]; then + echo "[{{MARKER}}] skipping: no tests marked with {{MARKER}} collected" + elif [ "$status" -ne 0 ]; then + exit "$status" + fi + +# Testing: Run tests marked with "unit" and not marked with "slow" +test-fast: + @just test-marker "unit and not slow" + +# Testing: Run tests marked with "smoke" +test-smoke: + @just test-marker "smoke" + +# Testing: Run tests marked with "regression" +test-regression: + @just test-marker "regression" + +# Testing: Run tests marked with "performance" +test-performance: + @just test-marker "performance" + +# Testing: Run tests marked with "property_based" +test-property: + @just test-marker "property_based" + + +# ====================================================================== +# Test Quality +# ====================================================================== + +# Testing: Run full test suite and report slowest tests +test-timed: + {{PYTEST}} {{PY_TESTPATH}} --durations=25 + +# Test Quality: Summarize coverage results from last test execution +cov: + #!/usr/bin/env bash + if [ -x {{SHOWCOV}} ]; then + {{SHOWCOV}} --sections summary --format human || true + else + echo "[cov] skipping: showcov ({{SHOWCOV}}) not found" + fi + +# Test Quality: List lines not covered by last test execution +cov-lines: + #!/usr/bin/env bash + if [ -x {{SHOWCOV}} ]; then + {{SHOWCOV}} --code --context 2,2 || true + else + echo "[cov-lines] skipping: showcov ({{SHOWCOV}}) not found" + fi + +# Test Quality: Run mutation testing on the test suite +mutation *ARGS: + #!/usr/bin/env bash + if [ -x {{NOOTNOOT}} ]; then + {{NOOTNOOT}} run {{ARGS}} + else + echo "[nootnoot] skipping: nootnoot not found ({{NOOTNOOT}})" + fi + +# Test Quality: Report mutation testing results +mutation-report: + #!/usr/bin/env bash + if [ -x {{NOOTNOOT}} ]; then + {{NOOTNOOT}} results + else + echo "[mutation-report] skipping: nootnoot not found ({{NOOTNOOT}})" + fi + +# Test Quality: Mutation score summary (for humans/CI) +mutation-score: + {{NOOTNOOT}} results | .venv/bin/python scripts/mutation_score.py + + +# Test Quality: Show the diff for every mutant listed by `nootnoot results` +mutation-diffs *ARGS: + #!/usr/bin/env bash + echo "=== Mutant Diffs ===" + .venv/bin/python scripts/mutation_diffs.py {{ARGS}} || true + + +# Test Quality: Test test flakiness by repeated runs of the test suite +flake N='5': + #!/usr/bin/env bash + set +e + rm -f .flake-log.txt + for i in $(seq 1 {{N}}); do + echo "=== run $i ===" | tee -a .flake-log.txt + {{PYTEST}} {{PY_TESTPATH}} --maxfail=50 --randomly-seed=last \ + | tee -a .flake-log.txt + done + set -e + +# Test Quality: Report test flakiness results +flake-report: + #!/usr/bin/env bash + if [ -f .flake-log.txt ]; then + if [ -x .venv/bin/python ] && [ -f scripts/flake_report.py ]; then + .venv/bin/python scripts/flake_report.py + else + echo "[flake-report] scripts/flake_report.py missing; basic summary:" + echo "Tests that failed in at least one run:" + grep -oE "FAILED .*::[a-zA-Z0-9_]+" .flake-log.txt \ + | sed 's/FAILED *//g' \ + | sort | uniq -c | sort -nr + fi + else + echo "[flake-report] no .flake-log.txt; run 'just flake' first" + fi + +# Test Quality: coverage of changed lines vs main +diff-cov BRANCH="origin/main": + #!/usr/bin/env bash + if [ ! -f .coverage.xml ]; then + echo "[diff-cov] .coverage.xml not found; run 'just test-strict' first" + exit 1 + fi + {{DIFF_COVER}} .coverage.xml --compare-branch={{BRANCH}} + +# Test Quality: strict coverage of changed lines vs main with threshold +diff-cov-strict BRANCH="origin/main" THRESHOLD="90": + #!/usr/bin/env bash + if [ ! -f .coverage.xml ]; then + echo "[diff-cov-strict] .coverage.xml not found; run 'just test-strict' first" + exit 1 + fi + echo "[diff-cov-strict] Enforcing changed-line coverage >= ${THRESHOLD}% against ${BRANCH}" + {{DIFF_COVER}} .coverage.xml --compare-branch={{BRANCH}} --fail-under={{THRESHOLD}} + + +# Test Quality: Performance regression check against baselines +perf-regression: + #!/usr/bin/env bash + if [ ! -d .perf_results ]; then + echo "[perf-regression] skipping: no .perf_results directory; run 'just test-performance' first" + exit 0 + fi + if [ -x .venv/bin/python ] && [ -f scripts/check_perf_regression.py ]; then + .venv/bin/python scripts/check_perf_regression.py + else + echo "[perf-regression] ERROR: scripts/check_perf_regression.py missing" + exit 1 + fi + + +# ====================================================================== +# Metrics +# ====================================================================== + +# Metrics: build or update wily index incrementally +wily-index: + #!/usr/bin/env bash + set -euo pipefail + stash_name="" + if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + if [ -n "$(git status --porcelain)" ]; then + stash_name="wily:temp:$(date -u +%Y%m%dT%H%M%SZ)" + git stash push -u -m "$stash_name" >/dev/null + trap 'git stash pop -q' EXIT + fi + fi + {{WILY}} --config {{WILY_CONFIG}} --cache {{WILY_CACHE}} build {{PY_SRC}} {{PY_TESTPATH}} + +# Metrics: report current metrics from index +wily-metrics FILE="": wily-index + #!/usr/bin/env bash + file="{{FILE}}" + if [ -z "$file" ]; then + file="{{PY_SRC}}/{{PYTHON_PACKAGE}}/__init__.py" + fi + {{WILY}} --config {{WILY_CONFIG}} --cache {{WILY_CACHE}} report "$file" + +# Metrics: report stats for all files +wily-stats: wily-index + #!/usr/bin/env bash + mapfile -t files < <(rg --files -g '*.py' {{PY_SRC}} {{PY_TESTPATH}}) + if [ "${#files[@]}" -eq 0 ]; then + echo "[wily-stats] no Python files found in {{PY_SRC}} or {{PY_TESTPATH}}" + exit 0 + fi + {{WILY}} --config {{WILY_CONFIG}} --cache {{WILY_CACHE}} diff --all --no-detail "${files[@]}" + + + +# Metrics: report-only (no gating) +metrics-report: + #!/usr/bin/env bash + echo "=== Metrics Report (non-gating) ===" + + echo "--- Coverage summary ---" + if [ -x {{SHOWCOV}} ]; then + {{SHOWCOV}} --sections summary --format human || true + else + echo "[metrics-report] showcov not found ({{SHOWCOV}}); skipping coverage summary" + fi + + echo + echo "--- Mutation score ---" + if [ -x {{NOOTNOOT}} ]; then + {{NOOTNOOT}} results | .venv/bin/python scripts/mutation_score.py || true + else + echo "[metrics-report] nootnoot not found ({{NOOTNOOT}}); skipping mutation score" + fi + + echo + echo "--- Complexity report ---" + if [ -x {{RADON}} ]; then + {{RADON}} cc -s -a {{PY_SRC}} || true + else + echo "[metrics-report] radon not found ({{RADON}}); skipping complexity report" + fi + + echo + echo "--- Duplication report ---" + {{JSCPD}} --pattern "{{PY_SRC}}/**/*.py" --pattern "{{PY_TESTPATH}}/**/*.py" --reporters console || true + + echo + echo "--- Flakiness report ---" + if [ -f .flake-log.txt ]; then + if [ -x .venv/bin/python ] && [ -f scripts/flake_report.py ]; then + .venv/bin/python scripts/flake_report.py || true + else + echo "[metrics-report] flake_report script missing; using basic grep summary" + grep -oE "FAILED .*::[a-zA-Z0-9_]+" .flake-log.txt \ + | sed 's/FAILED *//g' \ + | sort | uniq -c | sort -nr || true + fi + else + echo "[metrics-report] no .flake-log.txt; run 'just flake' first for flake metrics" + fi + + echo + echo "=== Metrics Report complete (non-gating) ===" + +# Metrics: enforce thresholds +metrics-gate: + #!/usr/bin/env bash + set -e + + echo "=== Metrics Gate (gating) ===" + + # 1) Ensure tests have proper markers and layout + if [ -x .venv/bin/python ] && [ -f scripts/check_test_markers.py ]; then + .venv/bin/python scripts/check_test_markers.py + else + echo "[metrics-gate] ERROR: scripts/check_test_markers.py not found" + exit 1 + fi + + # 2) Coverage: enforce changed-line coverage threshold (e.g. 90%) + if [ ! -f .coverage.xml ]; then + echo "[metrics-gate] .coverage.xml not found; run 'just test-strict' first" + exit 1 + fi + {{DIFF_COVER}} .coverage.xml --compare-branch=origin/main --fail-under=90 + + # 3) Complexity: enforce strict threshold + {{RADON}} cc -s -n 11 {{PY_SRC}} + + # 4) Mutation: enforce minimum score + if [ -x {{NOOTNOOT}} ]; then + {{NOOTNOOT}} results | .venv/bin/python scripts/mutation_score.py | tee .mutation-score.txt + if [ -x .venv/bin/python ] && [ -f scripts/check_mutation_threshold.py ]; then + export MUTATION_MIN="${MUTATION_MIN:-70}" + .venv/bin/python scripts/check_mutation_threshold.py < .mutation-score.txt + else + echo "[metrics-gate] ERROR: scripts/check_mutation_threshold.py not found" + exit 1 + fi + else + echo "[metrics-gate] ERROR: nootnoot not found ({{NOOTNOOT}})" + exit 1 + fi + + echo "=== Metrics Gate PASSED ===" + + +# ====================================================================== +# Documentation +# ====================================================================== + +# Documentation: Build documentation using `mkdocs` +build-docs: + #!/usr/bin/env bash + if [ -x {{MKDOCS}} ]; then + {{MKDOCS}} build + else + echo "[build-docs] skipping: mkdocs not found ({{MKDOCS}} or on PATH)" + fi + +# Documentation: Serve the documentation site locally +docs: build-docs + #!/usr/bin/env bash + if [ -x {{MKDOCS}} ]; then + python3 -m webbrowser http://127.0.0.1:8000 + {{MKDOCS}} serve --livereload + else + echo "[docs] skipping: mkdocs not found ({{MKDOCS}} or on PATH)" + fi + + +# ====================================================================== +# Build, packaging, publishing +# ====================================================================== + +# Production: Build Python artifacts with `uv build` +build: + {{UV}} build + +# Production: Publish to PyPI using `uv publish` +publish: + {{UV}} publish + + +# ====================================================================== +# Running +# ====================================================================== + +# Run: CLI mode via `python -m {{PYTHON_PACKAGE}}` +cli: setup + .venv/bin/python -m {{PYTHON_PACKAGE}} + + +# ====================================================================== +# Cleaning / maintenance +# ====================================================================== + +# Cleaning: Remove caches/build artifacts and prune uv cache +clean: + find . -name '__pycache__' -type d -prune -exec rm -rf '{}' + + rm -rf .ruff_cache .pytest_cache .mypy_cache .pytype + rm -rf .coverage .coverage.* coverage.xml htmlcov + rm -rf dist build + rm -rf logs + rm -rf .hypothesis .ropeproject .wily mutants + {{UV}} cache prune + +# Cleaning: Stash untracked (non-ignored) files (used by `scour`) +stash-untracked: + #!/usr/bin/env bash + if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + msg="scour:untracked:$(date -u +%Y%m%dT%H%M%SZ)" + if git ls-files --others --exclude-standard --directory --no-empty-directory | grep -q .; then + git ls-files --others --exclude-standard -z | xargs -0 git stash push -m "$msg" -- >/dev/null + echo "Stashed untracked (non-ignored) files as: $msg" + else + echo "No untracked (non-ignored) paths to stash." + fi + else + echo "[stash-untracked] not a git repository; skipping" + fi + +# Cleaning: Remove git-ignored files/dirs while keeping .venv +scour: clean stash-untracked + #!/usr/bin/env bash + if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git clean -fXd -e .venv + else + echo "[scour] not a git repository; skipping git clean" + fi + + +# ====================================================================== +# Composite flows +# ====================================================================== + +# Convenience: setup, lint, format, typecheck, build-docs, test, cov +fix: setup lint format typecheck lint-imports build-docs test cov + +# CI: lint/type/tests/coverage summary with tool fallbacks +check: setup format-no-fix lint-no-fix typecheck lint-imports test-strict cov sec-deps + +ci-pr: check diff-cov-strict sec-secrets sec-tools + +ci-nightly: setup test-strict complexity-strict dead-code mutation flake cov mutation-report flake-report test-property metrics-gate sec-secrets sec-tools sec-deps + +ci-slow: ci-nightly wily-index wily-metrics test-performance perf-regression diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..ccc018fd --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,22 @@ +site_name: nootnoot +site_description: Mutation testing for Python 3 +site_url: https://nootnoot.readthedocs.io/ +repo_url: https://github.com/boxed/nootnoot +repo_name: boxed/nootnoot +docs_dir: docs +nav: + - Home: index.md + - Architecture: ARCHITECTURE.md + - Contributing: CONTRIBUTING.md + - Changelog: HISTORY.md + - Authors: AUTHORS.md +theme: + name: mkdocs +markdown_extensions: + - admonition + - toc: + permalink: true + - pymdownx.snippets: + check_paths: true + base_path: + - . diff --git a/pyproject.toml b/pyproject.toml index 727d63a9..a64e6714 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,62 +1,186 @@ [project] -name = "mutmut" -version = "3.4.0" -description = "mutation testing for Python 3" -keyowrds = ["mutmut", "mutant", "mutation", "test", "testing"] -authors = [ + name = "nootnoot" + version = "3.4.6" + description = "mutation testing for Python 3" + keywords = ["nootnoot", "mutant", "mutation", "test", "testing"] + authors = [ { name = "Anders Hovmöller", email = "boxed@killingar.net" }, -] -requires-python = ">=3.10" -readme = "README.rst" -license = "BSD-3-Clause" -license-files = ["LICENSE"] + ] + requires-python = ">=3.12" + readme = "README.md" + license = "BSD-3-Clause" -classifiers = [ + classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Natural Language :: English", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", -] + ] -dependencies = [ + dependencies = [ "click>=8.0.0", "coverage>=7.3.0", "libcst>=1.8.5", "pytest>=6.2.5", + "rich>=14.2.0", "setproctitle>=1.1.0", "textual>=1.0.0", - "toml>=0.10.2 ; python_full_version < '3.11'", -] + ] -[project.urls] -Homepage = "https://github.com/boxed/mutmut" -Documentation = "https://mutmut.readthedocs.io/en/latest/" -Repository = "https://github.com/boxed/mutmut" -Issues = "https://github.com/boxed/mutmut/issues" -Changelog = "https://github.com/boxed/mutmut/blob/main/HISTORY.rst" + [project.urls] + Homepage = "https://github.com/josephcourtney/nootnoot" + # Documentation = "https://nootnoot.readthedocs.io/en/latest/" + Repository = "https://github.com/josephcourtney/nootnoot" + Issues = "https://github.com/josephcourtney/nootnoot/issues" + Changelog = "https://github.com/josephcourtney/nootnoot/blob/main/CHANGELOG.md" -[project.scripts] -mutmut = "mutmut.__main__:cli" + [project.scripts] + nootnoot = "nootnoot.cli.root:cli" +# =================================== build ==================================== [build-system] -requires = ["uv_build>=0.9.5,<0.10.0"] -build-backend = "uv_build" + requires = ["uv_build>=0.9.5,<0.10.0"] + build-backend = "uv_build" + + +[tool.uv] + default-groups = ["dev"] + [tool.uv.build-backend] + source-include = ["HISTORY.md"] + [tool.uv.sources] + showcov = { git = "https://github.com/josephcourtney/showcov.git", rev = "main" } -[tool.uv.build-backend] -source-include = ["HISTORY.rst"] [dependency-groups] -dev = [ - "pytest-asyncio>=1.0.0", -] + dev = [ + "coverage>=7.12.0", + "diff-cover>=9.7.2", + "hypothesis>=6.148.6", + "import-linter>=2.9", + "mkdocs>=1.6.1", + "pip-audit>=2.10.0", + "pymdown-extensions>=10.19.1", + "pytest>=9.0.1", + "pytest-asyncio>=1.3.0", + "pytest-cov>=7.0.0", + "pytest-randomly>=4.0.1", + "pytest-socket>=0.7.0", + "radon>=6.0.1", + "ruff>=0.14.7", + "rust-just>=1.43.1", + "showcov>=0.1.4", + "ty>=0.0.1a31", + "urllib3>=2.6.0", + "vulture>=2.14", + "wily>=1.12.2", + ] + docs = [ + "mkdocs>=1.6.0", + "pymdown-extensions>=10.12.1", + ] + + [project.optional-dependencies] + docs = [ + "mkdocs>=1.6.0", + "pymdown-extensions>=10.12.1", + ] + +# ==================================== lint ==================================== +[tool.ruff] + extend = "./ruff.default.toml" + extend-exclude = ["tests/data/test_generation/*.py"] + + [tool.ruff.lint] + ignore = [ + "TD002", # Missing author in TODO; try: `# TODO(): ...` or `# TODO @: ...` + "TD003", # Missing issue link for this TODO + "FIX002", # Line contains TODO, consider resolving the issue + # "N802", # Function name should be lowercase + # "ARG002", # Unused method argument + "DOC", # Documentation + "N818", # Exception name should be named with an Error suffix + ] + extend-per-file-ignores = { "tests/data/test_generation/*.py" = ["E226", "E302", "E999", "INP001", "W292"] } + +# ==================================== typecheck ==================================== +[tool.ty.src] + include = [ + "src", + ] +# =================================== test =================================== [tool.pytest.ini_options] -testpaths = [ - "tests", -] + minversion = "8.0" + testpaths = ["tests"] + python_files = ["test_*.py"] + addopts = [ + "--strict-markers", + "--strict-config", + "-ra", + "--cov-branch", + "--cov=nootnoot", + "--cov-report=xml", + ] + xfail_strict = true + log_cli = true + log_cli_level = "INFO" + log_cli_format = "%%(asctime)s [%%(levelname)s] %%(name)s - %%(message)s" + log_cli_date_format = "%%Y-%%m-%%d %%H:%%M:%%S" + filterwarnings = [ + "error::DeprecationWarning:nootnoot\\.", + "ignore::DeprecationWarning:some_known_third_party", + ] + markers = [ + "unit: Fast, isolated tests of individual functions/classes. No real I/O.", + "component: Tests of a subsystem via public APIs; may use local fakes or lightweight real deps.", + "integration: Tests involving real external dependencies (DB, HTTP APIs, queues).", + "system: End-to-end tests treating the app as a black box.", + "contract: Tests of API or schema contracts.", + "db: Tests that exercise database behaviour or schema.", + "smoke: Fast, critical-path tests for quick feedback.", + "slow: Known slow tests.", + "regression: Guards against previously reported bugs.", + "property_based: Property-based tests (e.g. Hypothesis).", + "observability: Tests asserting on logs, metrics, or traces.", + "security: Security-focused tests.", + "performance: Performance or performance-regression tests.", + "data_quality: Tests of data integrity or freshness.", + "perf: performance tests", + ] + asyncio_mode = "auto" + asyncio_default_fixture_loop_scope = "function" + +# =================================== test:coverage =================================== +[tool.coverage.run] + source = ["nootnoot"] + branch = true + parallel = true + +[tool.coverage.report] + show_missing = true + skip_covered = true + # Regexes for lines to exclude from consideration + exclude_also = [ + # Don't complain about missing debug-only code: + "def __repr__", + "if self\\.debug", + + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", + "raise NotImplementedError", + + # Don't complain if non-runnable code isn't run: + "if 0:", + "if __name__ == .__main__.:", + + # Don't complain about abstract methods, they aren't run: + "@(abc\\.)?abstractmethod", + ] + ignore_errors = true + +[tool.coverage.xml] + output = ".coverage.xml" diff --git a/ruff.default.toml b/ruff.default.toml new file mode 100644 index 00000000..076401b1 --- /dev/null +++ b/ruff.default.toml @@ -0,0 +1,199 @@ +include = [ + "pyproject.toml", + "src/**/*.py", + # "scripts/**/*.py", + "tests/**/*.py", +] + +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", +] + +target-version = "py312" +line-length = 110 +indent-width = 4 +output-format = "concise" +show-fixes = true +unsafe-fixes = true +fix = true +force-exclude = true +respect-gitignore = true + +[lint] + preview = true + + select = [ + "A", # flake8-builtins + "AIR", # Airflow + "ANN", # flake8-annotations + "ARG", # flake8-unused-arguments + "ASYNC", # flake8-async + "B", # flake8-bugbear + "BLE", # flake8-blind-except + "COM", # flake8-commas + "CPY", # flake8-copyright + "D", # pydocstyle + "DJ", # flake8-django + "DOC", # pydoclint + "DTZ", # flake8-datetimez + "E", # Error + "E", # pycodestyle Error + "EM", # flake8-errmsg + "ERA", # eradicate + "EXE", # flake8-executable + "F", # Pyflakes + "FA", # flake8-future-annotations + "FAST", # FastAPI + "FBT", # flake8-boolean-trap + "FIX", # flake8-fixme + "FLY", # flynt + "FURB", # refurb + "G", # flake8-logging-format + "I", # isort + "ICN", # flake8-import-conventions + "INP", # flake8-no-pep420 + "INT", # flake8-gettext + "ISC", # flake8-implicit-str-concat + "LOG", # flake8-logging + "N", # pep8-naming + "NPY", # NumPy-specific rules + "PD", # pandas-vet + "PERF", # Perflint + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PL", # Pylint + "PLC", # Convention + "PLE", # Error + "PLR", # Refactor + "PLW", # Warning + "PT", # flake8-pytest-style + "PTH", # flake8-use-pathlib + "PYI", # flake8-pyi + "Q", # flake8-quotes + "RET", # flake8-return + "RSE", # flake8-raise + "RUF", # Ruff-specific rules + "S", # flake8-bandit + "SIM", # flake8-simplify + "SLF", # flake8-self + "SLOT", # flake8-slots + "TC", # flake8-type-checking + "TD", # flake8-todos + "TID", # flake8-tidy-imports + "TRY", # tryceratops + "UP", # pyupgrade + "W", # pycodestyle Warning + "W", # Warning + "YTT", # flake8-2020 + ] + + # for development + # ignore when linting + ignore = [ + "ERA001", # Found commented-out code + "CPY001", # Missing copyright notice at top of file + + "D100", # Missing docstring in public module + "D101", # Missing docstring in public class + "D102", # Missing docstring in public method + "D103", # Missing docstring in public function + "D104", # Missing docstring in public package + + "T201", # `print` found + + "PLR0913", # Too many arguments in function definition + "PLR0917", # Too many positional arguments + + # "ANN101", # Missing type annotation for `self` in method + # "ANN002", # Missing type annotation for *args + # "ANN003", # Missing type annotation for **kwargs + + # conflict with formatting + "COM812", # missing trailing comma + "ISC001", # implicitly concatenated string literals on one line + ] + + [lint.per-file-ignores] + "tests/**/*.py" = [ + "ANN202", # Missing return type annotation for private function + "ARG001", # Unused function argument + "ARG002", # Unused method argument + "ARG005", # Unused lambda argument + "D105", # Missing docstring in magic method + "FBT003", # Boolean default value in function definition + "N803", # Argument name should be lowercase + "N806", # Variable in function should be lowercase + "PLC2701", # Private name import from external module + "PLR2004", # Magic value used in comparison + "PLR6301", # Method could be a function + "S101", # Use of `assert` detected + "S404", # `subprocess` module is possibly insecure + "S603", # `subprocess` call: check for execution of untrusted + "SLF001", # Private member accessed + "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in `renderable` + ] + + # disable autofix when linting + unfixable = [ + "F401", # delete unused imports + "F841", # remove assignment to unused variable + ] + + + # # for release + # # ignore when linting + # ignore = [ + # # "CPY001", # Missing copyright notice at top of file + # + # "PLR0913", # Too many arguments in function definition + # "PLR0917", # Too many positional arguments + # + # # conflict with formatting + # "COM812", # missing trailing comma + # "ISC001", # implicitly concatenated string literals on one line + # ] + # + # # disable autofix when linting + # unfixable = [] + + [lint.pydocstyle] + convention = "numpy" + + [lint.flake8-annotations] + ignore-fully-untyped = true + allow-star-arg-any = true + mypy-init-return = true + +[format] + docstring-code-format = true # format code in docstrings + docstring-code-line-length = "dynamic" + indent-style = "space" + line-ending = "auto" + preview = true + quote-style = "double" + skip-magic-trailing-comma = false diff --git a/src/mutmut/__init__.py b/src/mutmut/__init__.py deleted file mode 100644 index b330c7c4..00000000 --- a/src/mutmut/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -import importlib.metadata -from collections import defaultdict - -__version__ = importlib.metadata.version("mutmut") - - -duration_by_test = defaultdict(float) -stats_time = None -config = None - -_stats = set() -tests_by_mangled_function_name = defaultdict(set) -_covered_lines = None - -def _reset_globals(): - global duration_by_test, stats_time, config, _stats, tests_by_mangled_function_name - global _covered_lines - - duration_by_test.clear() - stats_time = None - config = None - _stats = set() - tests_by_mangled_function_name = defaultdict(set) - _covered_lines = None diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py deleted file mode 100644 index 4cad64d5..00000000 --- a/src/mutmut/__main__.py +++ /dev/null @@ -1,1527 +0,0 @@ -import ast -import fnmatch -import gc -import inspect -import itertools -import json -from multiprocessing import Pool, set_start_method, Lock -import os -import resource -import shutil -import signal -import subprocess -import sys -from abc import ABC -from collections import defaultdict -from configparser import ( - ConfigParser, - NoOptionError, - NoSectionError, -) -from contextlib import contextmanager -from dataclasses import dataclass -from datetime import ( - datetime, - timedelta, -) -from difflib import unified_diff -from io import TextIOBase -from json import JSONDecodeError -from math import ceil -from os import ( - makedirs, - walk, -) -from os.path import ( - isdir, - isfile, -) -from pathlib import Path -from signal import SIGTERM -from threading import Thread -from time import ( - process_time, - sleep, -) -from typing import ( - Dict, - List, - Union, - Optional, -) -import warnings - -import click -import libcst as cst -import libcst.matchers as m -from rich.text import Text -from setproctitle import setproctitle - -import mutmut -from mutmut.code_coverage import gather_coverage, get_covered_lines_for_file -from mutmut.file_mutation import mutate_file_contents -from mutmut.trampoline_templates import CLASS_NAME_SEPARATOR - -# Document: surviving mutants are retested when you ask mutmut to retest them, interactively in the UI or via command line - -# TODO: pragma no mutate should end up in `skipped` category -# TODO: hash of function. If hash changes, retest all mutants as mutant IDs are not stable - - -status_by_exit_code = defaultdict(lambda: 'suspicious', { - 1: 'killed', - 3: 'killed', # internal error in pytest means a kill - -24: 'killed', - 0: 'survived', - 5: 'no tests', - 2: 'check was interrupted by user', - None: 'not checked', - 33: 'no tests', - 34: 'skipped', - 35: 'suspicious', - 36: 'timeout', - -24: 'timeout', # SIGXCPU - 24: 'timeout', # SIGXCPU - 152: 'timeout', # SIGXCPU - 255: 'timeout', - -11: 'segfault', - -9: 'segfault', -}) - -emoji_by_status = { - 'survived': '🙁', - 'no tests': '🫥', - 'timeout': '⏰', - 'suspicious': '🤔', - 'skipped': '🔇', - 'check was interrupted by user': '🛑', - 'not checked': '?', - 'killed': '🎉', - 'segfault': '💥', -} - -exit_code_to_emoji = { - exit_code: emoji_by_status[status] - for exit_code, status in status_by_exit_code.items() -} - - -def guess_paths_to_mutate(): - """Guess the path to source code to mutate - - :rtype: str - """ - this_dir = os.getcwd().split(os.sep)[-1] - if isdir('lib'): - return ['lib'] - elif isdir('src'): - return ['src'] - elif isdir(this_dir): - return [this_dir] - elif isdir(this_dir.replace('-', '_')): - return [this_dir.replace('-', '_')] - elif isdir(this_dir.replace(' ', '_')): - return [this_dir.replace(' ', '_')] - elif isdir(this_dir.replace('-', '')): - return [this_dir.replace('-', '')] - elif isdir(this_dir.replace(' ', '')): - return [this_dir.replace(' ', '')] - if isfile(this_dir + '.py'): - return [this_dir + '.py'] - raise FileNotFoundError( - 'Could not figure out where the code to mutate is. ' - 'Please specify it by adding "paths_to_mutate=code_dir" in setup.cfg to the [mutmut] section.') - - -def record_trampoline_hit(name): - assert not name.startswith('src.'), f'Failed trampoline hit. Module name starts with `src.`, which is invalid' - if mutmut.config.max_stack_depth != -1: - f = inspect.currentframe() - c = mutmut.config.max_stack_depth - while c and f: - filename = f.f_code.co_filename - if 'pytest' in filename or 'hammett' in filename or 'unittest' in filename: - break - f = f.f_back - c -= 1 - - if not c: - return - - mutmut._stats.add(name) - - -def walk_all_files(): - for path in mutmut.config.paths_to_mutate: - if not isdir(path): - if isfile(path): - yield '', str(path) - continue - for root, dirs, files in walk(path): - for filename in files: - yield root, filename - - -def walk_source_files(): - for root, filename in walk_all_files(): - if filename.endswith('.py'): - yield Path(root) / filename - - -class MutmutProgrammaticFailException(Exception): - pass - - -class CollectTestsFailedException(Exception): - pass - - -class BadTestExecutionCommandsException(Exception): - def __init__(self, pytest_args: list[str]) -> None: - msg = f'Failed to run pytest with args: {pytest_args}. If your config sets debug=true, the original pytest error should be above.' - super().__init__(msg) - - -class InvalidGeneratedSyntaxException(Exception): - def __init__(self, file: Union[Path, str]) -> None: - super().__init__(f'Mutmut generated invalid python syntax for {file}. ' - 'If the original file has valid python syntax, please file an issue ' - 'with a minimal reproducible example file.') - - -def copy_src_dir(): - for path in mutmut.config.paths_to_mutate: - output_path: Path = Path('mutants') / path - if isdir(path): - shutil.copytree(path, output_path, dirs_exist_ok=True) - else: - output_path.parent.mkdir(exist_ok=True, parents=True) - shutil.copyfile(path, output_path) - -@dataclass -class FileMutationResult: - """Dataclass to transfer warnings and errors from child processes to the parent""" - warnings: list[Warning] - error: Optional[Exception] = None - -def create_mutants(max_children: int): - with Pool(processes=max_children) as p: - for result in p.imap_unordered(create_file_mutants, walk_source_files()): - for warning in result.warnings: - warnings.warn(warning) - if result.error: - raise result.error - -def create_file_mutants(path: Path) -> FileMutationResult: - try: - print(path) - output_path = Path('mutants') / path - makedirs(output_path.parent, exist_ok=True) - - if mutmut.config.should_ignore_for_mutation(path): - shutil.copy(path, output_path) - return FileMutationResult(warnings=[]) - else: - return create_mutants_for_file(path, output_path) - except Exception as e: - return FileMutationResult(warnings=[], error=e) - -def setup_source_paths(): - # ensure that the mutated source code can be imported by the tests - source_code_paths = [Path('.'), Path('src'), Path('source')] - for path in source_code_paths: - mutated_path = Path('mutants') / path - if mutated_path.exists(): - sys.path.insert(0, str(mutated_path.absolute())) - - # ensure that the original code CANNOT be imported by the tests - for path in source_code_paths: - for i in range(len(sys.path)): - while i < len(sys.path) and Path(sys.path[i]).resolve() == path.resolve(): - del sys.path[i] - -def store_lines_covered_by_tests(): - if mutmut.config.mutate_only_covered_lines: - mutmut._covered_lines = gather_coverage(PytestRunner(), list(walk_source_files())) - -def copy_also_copy_files(): - assert isinstance(mutmut.config.also_copy, list) - for path in mutmut.config.also_copy: - print(' also copying', path) - path = Path(path) - destination = Path('mutants') / path - if not path.exists(): - continue - if path.is_file(): - shutil.copy(path, destination) - else: - shutil.copytree(path, destination, dirs_exist_ok=True) - -def create_mutants_for_file(filename, output_path) -> FileMutationResult: - input_stat = os.stat(filename) - warnings: list[Warning] = [] - - with open(filename) as f: - source = f.read() - - with open(output_path, 'w') as out: - try: - mutant_names, hash_by_function_name = write_all_mutants_to_file(out=out, source=source, filename=filename) - except cst.ParserSyntaxError as e: - # if libcst cannot parse it, then copy the source without any mutations - warnings.append(SyntaxWarning(f'Unsupported syntax in {filename} ({str(e)}), skipping')) - out.write(source) - mutant_names, hash_by_function_name = [], {} - - # validate no syntax errors of mutants - with open(output_path) as f: - try: - ast.parse(f.read()) - except (IndentationError, SyntaxError) as e: - invalid_syntax_error = InvalidGeneratedSyntaxException(output_path) - invalid_syntax_error.__cause__ = e - return FileMutationResult(warnings=warnings, error=invalid_syntax_error) - - source_file_mutation_data = SourceFileMutationData(path=filename) - module_name = strip_prefix(str(filename)[:-len(filename.suffix)].replace(os.sep, '.'), prefix='src.') - - source_file_mutation_data.exit_code_by_key = { - '.'.join([module_name, x]).replace('.__init__.', '.'): None - for x in mutant_names - } - source_file_mutation_data.hash_by_function_name = hash_by_function_name - assert None not in hash_by_function_name - source_file_mutation_data.save() - - os.utime(output_path, (input_stat.st_atime, input_stat.st_mtime)) - return FileMutationResult(warnings=warnings) - - -def write_all_mutants_to_file(*, out, source, filename): - result, mutant_names = mutate_file_contents(filename, source, get_covered_lines_for_file(filename, mutmut._covered_lines)) - out.write(result) - - # TODO: function hashes are currently not used. Reimplement this when needed. - hash_by_function_name = {} - - return mutant_names, hash_by_function_name - - -class SourceFileMutationData: - def __init__(self, *, path): - self.estimated_time_of_tests_by_mutant = {} - self.path = path - self.meta_path = Path('mutants') / (str(path) + '.meta') - self.key_by_pid = {} - self.exit_code_by_key = {} - self.durations_by_key = {} - self.hash_by_function_name = {} - self.start_time_by_pid = {} - - def load(self): - try: - with open(self.meta_path) as f: - meta = json.load(f) - except FileNotFoundError: - return - - self.exit_code_by_key = meta.pop('exit_code_by_key') - self.hash_by_function_name = meta.pop('hash_by_function_name') - self.durations_by_key = meta.pop('durations_by_key') - self.estimated_time_of_tests_by_mutant = meta.pop('estimated_durations_by_key') - assert not meta, f'Meta file {self.meta_path} constains unexpected keys: {set(meta.keys())}' - - def register_pid(self, *, pid, key): - self.key_by_pid[pid] = key - with START_TIMES_BY_PID_LOCK: - self.start_time_by_pid[pid] = datetime.now() - - def register_result(self, *, pid, exit_code): - assert self.key_by_pid[pid] in self.exit_code_by_key - key = self.key_by_pid[pid] - self.exit_code_by_key[key] = exit_code - self.durations_by_key[key] = (datetime.now() - self.start_time_by_pid[pid]).total_seconds() - # TODO: maybe rate limit this? Saving on each result can slow down mutation testing a lot if the test run is fast. - del self.key_by_pid[pid] - with START_TIMES_BY_PID_LOCK: - del self.start_time_by_pid[pid] - self.save() - - def stop_children(self): - for pid in self.key_by_pid.keys(): - os.kill(pid, SIGTERM) - - def save(self): - with open(self.meta_path, 'w') as f: - json.dump(dict( - exit_code_by_key=self.exit_code_by_key, - hash_by_function_name=self.hash_by_function_name, - durations_by_key=self.durations_by_key, - estimated_durations_by_key=self.estimated_time_of_tests_by_mutant, - ), f, indent=4) - - -def unused(*_): - pass - - -def strip_prefix(s, *, prefix, strict=False): - if s.startswith(prefix): - return s[len(prefix):] - assert strict is False, f"String '{s}' does not start with prefix '{prefix}'" - return s - - -class TestRunner(ABC): - def run_stats(self, *, tests): - raise NotImplementedError() - - def run_forced_fail(self): - raise NotImplementedError() - - def prepare_main_test_run(self): - pass - - def run_tests(self, *, mutant_name, tests): - raise NotImplementedError() - - def list_all_tests(self): - raise NotImplementedError() - - -@contextmanager -def change_cwd(path): - old_cwd = os.path.abspath(os.getcwd()) - os.chdir(path) - try: - yield - finally: - os.chdir(old_cwd) - - -def collected_test_names(): - return set(mutmut.duration_by_test.keys()) - - -class ListAllTestsResult: - def __init__(self, *, ids): - assert isinstance(ids, set) - self.ids = ids - - def clear_out_obsolete_test_names(self): - count_before = sum(len(x) for x in mutmut.tests_by_mangled_function_name) - mutmut.tests_by_mangled_function_name = defaultdict(set, **{ - k: {test_name for test_name in test_names if test_name in self.ids} - for k, test_names in mutmut.tests_by_mangled_function_name.items() - }) - count_after = sum(len(x) for x in mutmut.tests_by_mangled_function_name) - if count_before != count_after: - print(f'Removed {count_before - count_after} obsolete test names') - save_stats() - - def new_tests(self): - return self.ids - collected_test_names() - - -class PytestRunner(TestRunner): - def __init__(self): - self._pytest_add_cli_args: List[str] = mutmut.config.pytest_add_cli_args - self._pytest_add_cli_args_test_selection: List[str] = mutmut.config.pytest_add_cli_args_test_selection - - # tests_dir is a special case of a test selection option, - # so also use pytest_add_cli_args_test_selection for the implementation - self._pytest_add_cli_args_test_selection += mutmut.config.tests_dir - - - # noinspection PyMethodMayBeStatic - def execute_pytest(self, params: list[str], **kwargs): - import pytest - params = ['--rootdir=.', '--tb=native'] + params + self._pytest_add_cli_args - if mutmut.config.debug: - params = ['-vv'] + params - print('python -m pytest ', ' '.join([f'"{param}"' for param in params])) - exit_code = int(pytest.main(params, **kwargs)) - if mutmut.config.debug: - print(' exit code', exit_code) - if exit_code == 4: - raise BadTestExecutionCommandsException(params) - return exit_code - - def run_stats(self, *, tests): - class StatsCollector: - # noinspection PyMethodMayBeStatic - def pytest_runtest_logstart(self, nodeid, location): - mutmut.duration_by_test[nodeid] = 0 - - # noinspection PyMethodMayBeStatic - def pytest_runtest_teardown(self, item, nextitem): - unused(nextitem) - for function in mutmut._stats: - mutmut.tests_by_mangled_function_name[function].add(strip_prefix(item._nodeid, prefix='mutants/')) - mutmut._stats.clear() - - # noinspection PyMethodMayBeStatic - def pytest_runtest_makereport(self, item, call): - mutmut.duration_by_test[item.nodeid] += call.duration - - stats_collector = StatsCollector() - - pytest_args = ['-x', '-q'] - if tests: - pytest_args += list(tests) - else: - pytest_args += self._pytest_add_cli_args_test_selection - with change_cwd('mutants'): - return int(self.execute_pytest(pytest_args, plugins=[stats_collector])) - - def run_tests(self, *, mutant_name, tests): - pytest_args = ['-x', '-q', '-p', 'no:randomly', '-p', 'no:random-order'] - if tests: - pytest_args += list(tests) - else: - pytest_args += self._pytest_add_cli_args_test_selection - with change_cwd('mutants'): - return int(self.execute_pytest(pytest_args)) - - def run_forced_fail(self): - pytest_args = ['-x', '-q'] + self._pytest_add_cli_args_test_selection - with change_cwd('mutants'): - return int(self.execute_pytest(pytest_args)) - - def list_all_tests(self): - class TestsCollector: - def __init__(self): - self.collected_nodeids = set() - self.deselected_nodeids = set() - - def pytest_collection_modifyitems(self, items): - self.collected_nodeids |= {item.nodeid for item in items} - - def pytest_deselected(self, items): - self.deselected_nodeids |= {item.nodeid for item in items} - - collector = TestsCollector() - - tests_dir = mutmut.config.tests_dir - pytest_args = ['-x', '-q', '--collect-only'] + self._pytest_add_cli_args_test_selection - - with change_cwd('mutants'): - exit_code = int(self.execute_pytest(pytest_args, plugins=[collector])) - if exit_code != 0: - raise CollectTestsFailedException() - - selected_nodeids = collector.collected_nodeids - collector.deselected_nodeids - return ListAllTestsResult(ids=selected_nodeids) - - -class HammettRunner(TestRunner): - def __init__(self): - self.hammett_kwargs = None - - def run_stats(self, *, tests): - import hammett - print('Running hammett stats...') - - def post_test_callback(_name, **_): - for function in mutmut._stats: - mutmut.tests_by_mangled_function_name[function].add(_name) - mutmut._stats.clear() - - return hammett.main(quiet=True, fail_fast=True, disable_assert_analyze=True, post_test_callback=post_test_callback, use_cache=False, insert_cwd=False) - - def run_forced_fail(self): - import hammett - return hammett.main(quiet=True, fail_fast=True, disable_assert_analyze=True, use_cache=False, insert_cwd=False) - - def prepare_main_test_run(self): - import hammett - self.hammett_kwargs = hammett.main_setup( - quiet=True, - fail_fast=True, - disable_assert_analyze=True, - use_cache=False, - insert_cwd=False, - ) - - def run_tests(self, *, mutant_name, tests): - import hammett - hammett.Config.workerinput = dict(workerinput=f'_{mutant_name}') - return hammett.main_run_tests(**self.hammett_kwargs, tests=tests) - - -def mangled_name_from_mutant_name(mutant_name): - assert '__mutmut_' in mutant_name, mutant_name - return mutant_name.partition('__mutmut_')[0] - - -def orig_function_and_class_names_from_key(mutant_name): - r = mangled_name_from_mutant_name(mutant_name) - _, _, r = r.rpartition('.') - class_name = None - if CLASS_NAME_SEPARATOR in r: - class_name = r[r.index(CLASS_NAME_SEPARATOR) + 1: r.rindex(CLASS_NAME_SEPARATOR)] - r = r[r.rindex(CLASS_NAME_SEPARATOR) + 1:] - else: - assert r.startswith('x_'), r - r = r[2:] - return r, class_name - - -spinner = itertools.cycle('⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏') - - -def status_printer(): - """Manage the printing and in-place updating of a line of characters - - .. note:: - If the string is longer than a line, then in-place updating may not - work (it will print a new line at each refresh). - """ - last_len = [0] - last_update = [datetime(1900, 1, 1)] - update_threshold = timedelta(seconds=0.1) - - def p(s, *, force_output=False): - if not force_output and (datetime.now() - last_update[0]) < update_threshold: - return - s = next(spinner) + ' ' + s - len_s = len(s) - output = '\r' + s + (' ' * max(last_len[0] - len_s, 0)) - sys.__stdout__.write(output) - sys.__stdout__.flush() - last_len[0] = len_s - return p - - -print_status = status_printer() - - -@dataclass -class Stat: - not_checked: int - killed: int - survived: int - total: int - no_tests: int - skipped: int - suspicious: int - timeout: int - check_was_interrupted_by_user: int - segfault: int - - -def collect_stat(m: SourceFileMutationData): - r = { - k.replace(' ', '_'): 0 - for k in status_by_exit_code.values() - } - for k, v in m.exit_code_by_key.items(): - # noinspection PyTypeChecker - r[status_by_exit_code[v].replace(' ', '_')] += 1 - return Stat( - **r, - total=sum(r.values()), - ) - - -def calculate_summary_stats(source_file_mutation_data_by_path): - stats = [collect_stat(x) for x in source_file_mutation_data_by_path.values()] - return Stat( - not_checked=sum(x.not_checked for x in stats), - killed=sum(x.killed for x in stats), - survived=sum(x.survived for x in stats), - total=sum(x.total for x in stats), - no_tests=sum(x.no_tests for x in stats), - skipped=sum(x.skipped for x in stats), - suspicious=sum(x.suspicious for x in stats), - timeout=sum(x.timeout for x in stats), - check_was_interrupted_by_user=sum(x.check_was_interrupted_by_user for x in stats), - segfault=sum(x.segfault for x in stats), - ) - - -def print_stats(source_file_mutation_data_by_path, force_output=False): - s = calculate_summary_stats(source_file_mutation_data_by_path) - print_status(f'{(s.total - s.not_checked)}/{s.total} 🎉 {s.killed} 🫥 {s.no_tests} ⏰ {s.timeout} 🤔 {s.suspicious} 🙁 {s.survived} 🔇 {s.skipped}', force_output=force_output) - - -def run_forced_fail_test(runner): - os.environ['MUTANT_UNDER_TEST'] = 'fail' - with CatchOutput(spinner_title='Running forced fail test') as catcher: - try: - if runner.run_forced_fail() == 0: - catcher.dump_output() - print("FAILED: Unable to force test failures") - raise SystemExit(1) - except MutmutProgrammaticFailException: - pass - os.environ['MUTANT_UNDER_TEST'] = '' - print(' done') - - -class CatchOutput: - def __init__(self, callback=lambda s: None, spinner_title=None): - self.strings = [] - self.spinner_title = spinner_title or '' - if mutmut.config is not None and mutmut.config.debug: - self.spinner_title += '\n' - - class StdOutRedirect(TextIOBase): - def __init__(self, catcher): - self.catcher = catcher - - def write(self, s): - callback(s) - if spinner_title: - print_status(spinner_title) - self.catcher.strings.append(s) - return len(s) - self.redirect = StdOutRedirect(self) - - # noinspection PyMethodMayBeStatic - def stop(self): - sys.stdout = sys.__stdout__ - sys.stderr = sys.__stderr__ - - def start(self): - if self.spinner_title: - print_status(self.spinner_title) - sys.stdout = self.redirect - sys.stderr = self.redirect - if mutmut.config.debug: - self.stop() - - def dump_output(self): - self.stop() - print() - for line in self.strings: - print(line, end='') - - def __enter__(self): - self.start() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.stop() - if self.spinner_title: - print() - - -@dataclass -class Config: - also_copy: List[Path] - do_not_mutate: List[str] - max_stack_depth: int - debug: bool - paths_to_mutate: List[Path] - pytest_add_cli_args: List[str] - pytest_add_cli_args_test_selection: List[str] - tests_dir: List[str] - mutate_only_covered_lines: bool - - def should_ignore_for_mutation(self, path): - if not str(path).endswith('.py'): - return True - for p in self.do_not_mutate: - if fnmatch.fnmatch(path, p): - return True - return False - - -def config_reader(): - path = Path('pyproject.toml') - if path.exists(): - if sys.version_info >= (3, 11): - from tomllib import loads - else: - # noinspection PyPackageRequirements - from toml import loads - data = loads(path.read_text('utf-8')) - - try: - config = data['tool']['mutmut'] - except KeyError: - pass - else: - def s(key, default): - try: - result = config[key] - except KeyError: - return default - return result - return s - - config_parser = ConfigParser() - config_parser.read('setup.cfg') - - def s(key: str, default): - try: - result = config_parser.get('mutmut', key) - except (NoOptionError, NoSectionError): - return default - if isinstance(default, list): - if '\n' in result: - result = [x for x in result.split("\n") if x] - else: - result = [result] - elif isinstance(default, bool): - result = result.lower() in ('1', 't', 'true') - elif isinstance(default, int): - result = int(result) - return result - return s - - -def ensure_config_loaded(): - if mutmut.config is None: - mutmut.config = load_config() - - -def load_config(): - s = config_reader() - - return Config( - do_not_mutate=s('do_not_mutate', []), - also_copy=[ - Path(y) - for y in s('also_copy', []) - ] + [ - Path('tests/'), - Path('test/'), - Path('setup.cfg'), - Path('pyproject.toml'), - ] + list(Path('.').glob('test*.py')), - max_stack_depth=s('max_stack_depth', -1), - debug=s('debug', False), - mutate_only_covered_lines=s('mutate_only_covered_lines', False), - paths_to_mutate=[ - Path(y) - for y in s('paths_to_mutate', []) - ] or guess_paths_to_mutate(), - tests_dir=s('tests_dir', []), - pytest_add_cli_args=s('pytest_add_cli_args', []), - pytest_add_cli_args_test_selection=s('pytest_add_cli_args_test_selection', []), - ) - - - -@click.group() -@click.version_option() -def cli(): - pass - - -def run_stats_collection(runner, tests=None): - if tests is None: - tests = [] # Meaning all... - - os.environ['MUTANT_UNDER_TEST'] = 'stats' - os.environ['PY_IGNORE_IMPORTMISMATCH'] = '1' - start_cpu_time = process_time() - - with CatchOutput(spinner_title='Running stats') as output_catcher: - collect_stats_exit_code = runner.run_stats(tests=tests) - if collect_stats_exit_code != 0: - output_catcher.dump_output() - print(f'failed to collect stats. runner returned {collect_stats_exit_code}') - exit(1) - # ensure that at least one mutant has associated tests - num_associated_tests = sum(len(tests) for tests in mutmut.tests_by_mangled_function_name.values()) - if num_associated_tests == 0: - output_catcher.dump_output() - print('Stopping early, because we could not find any test case for any mutant. It seems that the selected tests do not cover any code that we mutated.') - if not mutmut.config.debug: - print('You can set debug=true to see the executed test names in the output above.') - else: - print('In the last pytest run above, you can see which tests we executed.') - print('You can use mutmut browse to check which parts of the source code we mutated.') - print('If some of the mutated code should be covered by the executed tests, consider opening an issue (with a MRE if possible).') - exit(1) - - print(' done') - if not tests: # again, meaning all - mutmut.stats_time = process_time() - start_cpu_time - - if not collected_test_names(): - print('failed to collect stats, no active tests found') - exit(1) - - save_stats() - - -def collect_or_load_stats(runner): - did_load = load_stats() - - if not did_load: - # Run full stats - run_stats_collection(runner) - else: - # Run incremental stats - with CatchOutput(spinner_title='Listing all tests') as output_catcher: - os.environ['MUTANT_UNDER_TEST'] = 'list_all_tests' - try: - all_tests_result = runner.list_all_tests() - except CollectTestsFailedException: - output_catcher.dump_output() - print('Failed to collect list of tests') - exit(1) - - all_tests_result.clear_out_obsolete_test_names() - - new_tests = all_tests_result.new_tests() - - if new_tests: - print(f'Found {len(new_tests)} new tests, rerunning stats collection') - run_stats_collection(runner, tests=new_tests) - - -def load_stats(): - did_load = False - try: - with open('mutants/mutmut-stats.json') as f: - data = json.load(f) - for k, v in data.pop('tests_by_mangled_function_name').items(): - mutmut.tests_by_mangled_function_name[k] |= set(v) - mutmut.duration_by_test = data.pop('duration_by_test') - mutmut.stats_time = data.pop('stats_time') - assert not data, data - did_load = True - except (FileNotFoundError, JSONDecodeError): - pass - return did_load - - -def save_stats(): - with open('mutants/mutmut-stats.json', 'w') as f: - json.dump(dict( - tests_by_mangled_function_name={k: list(v) for k, v in mutmut.tests_by_mangled_function_name.items()}, - duration_by_test=mutmut.duration_by_test, - stats_time=mutmut.stats_time, - ), f, indent=4) - - -def collect_source_file_mutation_data(*, mutant_names): - source_file_mutation_data_by_path: Dict[str, SourceFileMutationData] = {} - - for path in walk_source_files(): - if mutmut.config.should_ignore_for_mutation(path): - continue - assert path not in source_file_mutation_data_by_path - m = SourceFileMutationData(path=path) - m.load() - source_file_mutation_data_by_path[str(path)] = m - - mutants = [ - (m, mutant_name, result) - for path, m in source_file_mutation_data_by_path.items() - for mutant_name, result in m.exit_code_by_key.items() - ] - - if mutant_names: - filtered_mutants = [ - (m, key, result) - for m, key, result in mutants - if key in mutant_names or any(fnmatch.fnmatch(key, mutant_name) for mutant_name in mutant_names) - ] - assert filtered_mutants, f'Filtered for specific mutants, but nothing matches\n\nFilter: {mutant_names}' - mutants = filtered_mutants - return mutants, source_file_mutation_data_by_path - - -def estimated_worst_case_time(mutant_name): - tests = mutmut.tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), set()) - return sum(mutmut.duration_by_test[t] for t in tests) - - -@cli.command() -@click.argument('mutant_names', required=False, nargs=-1) -def print_time_estimates(mutant_names): - assert isinstance(mutant_names, (tuple, list)), mutant_names - ensure_config_loaded() - - runner = PytestRunner() - runner.prepare_main_test_run() - - collect_or_load_stats(runner) - - mutants, source_file_mutation_data_by_path = collect_source_file_mutation_data(mutant_names=mutant_names) - - times_and_keys = [ - (estimated_worst_case_time(mutant_name), mutant_name) - for m, mutant_name, result in mutants - ] - - for time, key in sorted(times_and_keys): - if not time: - print(f'', key) - else: - print(f'{int(time*1000)}ms', key) - - -@cli.command() -@click.argument('mutant_name', required=True, nargs=1) -def tests_for_mutant(mutant_name): - if not load_stats(): - print('Failed to load stats. Please run mutmut first to collect stats.') - exit(1) - - tests = tests_for_mutant_names([mutant_name]) - for test in sorted(tests): - print(test) - - -def stop_all_children(mutants): - for m, _, _ in mutants: - m.stop_children() - -# used to copy the global mutmut.config to subprocesses -set_start_method('fork') -START_TIMES_BY_PID_LOCK = Lock() - -def timeout_checker(mutants): - def inner_timeout_checker(): - while True: - sleep(1) - - now = datetime.now() - for m, mutant_name, result in mutants: - # copy dict inside lock, so it is not modified by another process while we iterate it - with START_TIMES_BY_PID_LOCK: - start_times_by_pid = dict(m.start_time_by_pid) - for pid, start_time in start_times_by_pid.items(): - run_time = now - start_time - if run_time.total_seconds() > (m.estimated_time_of_tests_by_mutant[mutant_name] + 1) * 15: - try: - os.kill(pid, signal.SIGXCPU) - except ProcessLookupError: - pass - return inner_timeout_checker - - -@cli.command() -@click.option('--max-children', type=int) -@click.argument('mutant_names', required=False, nargs=-1) -def run(mutant_names, *, max_children): - assert isinstance(mutant_names, (tuple, list)), mutant_names - _run(mutant_names, max_children) - -# separate function, so we can call it directly from the tests -def _run(mutant_names: Union[tuple, list], max_children: Union[None, int]): - # TODO: run no-ops once in a while to detect if we get false negatives - # TODO: we should be able to get information on which tests killed mutants, which means we can get a list of tests and how many mutants each test kills. Those that kill zero mutants are redundant! - os.environ['MUTANT_UNDER_TEST'] = 'mutant_generation' - ensure_config_loaded() - - if max_children is None: - max_children = os.cpu_count() or 4 - - start = datetime.now() - makedirs(Path('mutants'), exist_ok=True) - with CatchOutput(spinner_title='Generating mutants'): - copy_src_dir() - copy_also_copy_files() - setup_source_paths() - store_lines_covered_by_tests() - create_mutants(max_children) - - time = datetime.now() - start - print(f' done in {round(time.total_seconds()*1000)}ms', ) - - # TODO: config/option for runner - # runner = HammettRunner() - runner = PytestRunner() - runner.prepare_main_test_run() - - # TODO: run these steps only if we have mutants to test - - collect_or_load_stats(runner) - - mutants, source_file_mutation_data_by_path = collect_source_file_mutation_data(mutant_names=mutant_names) - - os.environ['MUTANT_UNDER_TEST'] = '' - with CatchOutput(spinner_title='Running clean tests') as output_catcher: - tests = tests_for_mutant_names(mutant_names) - - clean_test_exit_code = runner.run_tests(mutant_name=None, tests=tests) - if clean_test_exit_code != 0: - output_catcher.dump_output() - print('Failed to run clean test') - exit(1) - print(' done') - - # this can't be the first thing, because it can fail deep inside pytest/django setup and then everything is destroyed - run_forced_fail_test(runner) - - runner.prepare_main_test_run() - - def read_one_child_exit_status(): - pid, wait_status = os.wait() - exit_code = os.waitstatus_to_exitcode(wait_status) - if mutmut.config.debug: - print(' worker exit code', exit_code) - source_file_mutation_data_by_pid[pid].register_result(pid=pid, exit_code=exit_code) - - source_file_mutation_data_by_pid: Dict[int, SourceFileMutationData] = {} # many pids map to one MutationData - running_children = 0 - count_tried = 0 - - # Run estimated fast mutants first, calculated as the estimated time for a surviving mutant. - mutants = sorted(mutants, key=lambda x: estimated_worst_case_time(x[1])) - - gc.freeze() - - start = datetime.now() - try: - print('Running mutation testing') - - # Calculate times of tests - for m, mutant_name, result in mutants: - mutant_name = mutant_name.replace('__init__.', '') - tests = mutmut.tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), []) - estimated_time_of_tests = sum(mutmut.duration_by_test[test_name] for test_name in tests) - m.estimated_time_of_tests_by_mutant[mutant_name] = estimated_time_of_tests - - Thread(target=timeout_checker(mutants), daemon=True).start() - - # Now do mutation - for m, mutant_name, result in mutants: - print_stats(source_file_mutation_data_by_path) - - mutant_name = mutant_name.replace('__init__.', '') - - # Rerun mutant if it's explicitly mentioned, but otherwise let the result stand - if not mutant_names and result is not None: - continue - - tests = mutmut.tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), []) - - # print(tests) - if not tests: - m.exit_code_by_key[mutant_name] = 33 - m.save() - continue - - pid = os.fork() - if not pid: - # In the child - os.environ['MUTANT_UNDER_TEST'] = mutant_name - setproctitle(f'mutmut: {mutant_name}') - - # Run fast tests first - tests = sorted(tests, key=lambda test_name: mutmut.duration_by_test[test_name]) - if not tests: - os._exit(33) - - estimated_time_of_tests = m.estimated_time_of_tests_by_mutant[mutant_name] - cpu_time_limit = ceil((estimated_time_of_tests + 1) * 30 + process_time()) - # signal SIGXCPU after . One second later signal SIGKILL if it is still running - resource.setrlimit(resource.RLIMIT_CPU, (cpu_time_limit, cpu_time_limit + 1)) - - with CatchOutput(): - result = runner.run_tests(mutant_name=mutant_name, tests=tests) - - if result != 0: - # TODO: write failure information to stdout? - pass - os._exit(result) - else: - # in the parent - source_file_mutation_data_by_pid[pid] = m - m.register_pid(pid=pid, key=mutant_name) - running_children += 1 - - if running_children >= max_children: - read_one_child_exit_status() - count_tried += 1 - running_children -= 1 - - try: - while running_children: - read_one_child_exit_status() - count_tried += 1 - running_children -= 1 - except ChildProcessError: - pass - except KeyboardInterrupt: - print('Stopping...') - stop_all_children(mutants) - - t = datetime.now() - start - - print_stats(source_file_mutation_data_by_path, force_output=True) - print() - print(f'{count_tried / t.total_seconds():.2f} mutations/second') - - if mutant_names: - print() - print('Mutant results') - print('--------------') - exit_code_by_key = {} - # If the user gave a specific list of mutants, print result for these specifically - for m, mutant_name, result in mutants: - exit_code_by_key[mutant_name] = m.exit_code_by_key[mutant_name] - - for mutant_name, exit_code in sorted(exit_code_by_key.items()): - print(emoji_by_status.get(status_by_exit_code[exit_code], '?'), mutant_name) - - print() - - -def tests_for_mutant_names(mutant_names): - tests = set() - for mutant_name in mutant_names: - if '*' in mutant_name: - for name, tests_of_this_name in mutmut.tests_by_mangled_function_name.items(): - if fnmatch.fnmatch(name, mutant_name): - tests |= set(tests_of_this_name) - else: - tests |= set(mutmut.tests_by_mangled_function_name[mangled_name_from_mutant_name(mutant_name)]) - return tests - - -@cli.command() -@click.option('--all', default=False) -def results(all): - ensure_config_loaded() - for path in walk_source_files(): - if not str(path).endswith('.py'): - continue - m = SourceFileMutationData(path=path) - m.load() - for k, v in m.exit_code_by_key.items(): - status = status_by_exit_code[v] - if status == 'killed' and not all: - continue - print(f' {k}: {status}') - - -def read_mutants_module(path) -> cst.Module: - with open(Path('mutants') / path) as f: - return cst.parse_module(f.read()) - - -def read_orig_module(path) -> cst.Module: - with open(path) as f: - return cst.parse_module(f.read()) - - -def find_top_level_function_or_method(module: cst.Module, name: str) -> Union[cst.FunctionDef, None]: - name = name.split('.')[-1] - for child in module.body: - if isinstance(child, cst.FunctionDef) and child.name.value == name: - return child - if isinstance(child, cst.ClassDef) and isinstance(child.body, cst.IndentedBlock): - for method in child.body.body: - if isinstance(method, cst.FunctionDef) and method.name.value == name: - return method - - return None - - -def read_original_function(module: cst.Module, mutant_name: str): - orig_function_name, _ = orig_function_and_class_names_from_key(mutant_name) - orig_name = mangled_name_from_mutant_name(mutant_name) + '__mutmut_orig' - - result = find_top_level_function_or_method(module, orig_name) - if not result: - raise FileNotFoundError(f'Could not find original function "{orig_function_name}"') - return result.with_changes(name = cst.Name(orig_function_name)) - - -def read_mutant_function(module: cst.Module, mutant_name: str): - orig_function_name, _ = orig_function_and_class_names_from_key(mutant_name) - - result = find_top_level_function_or_method(module, mutant_name) - if not result: - raise FileNotFoundError(f'Could not find original function "{orig_function_name}"') - return result.with_changes(name = cst.Name(orig_function_name)) - - -def find_mutant(mutant_name): - for path in walk_source_files(): - if mutmut.config.should_ignore_for_mutation(path): - continue - - m = SourceFileMutationData(path=path) - m.load() - if mutant_name in m.exit_code_by_key: - return m - - raise FileNotFoundError(f'Could not find mutant {mutant_name}') - - -def get_diff_for_mutant(mutant_name, source=None, path=None): - if path is None: - m = find_mutant(mutant_name) - path = m.path - status = status_by_exit_code[m.exit_code_by_key[mutant_name]] - else: - status = 'not checked' - - print(f'# {mutant_name}: {status}') - - if source is None: - module = read_mutants_module(path) - else: - module = cst.parse_module(source) - orig_code = cst.Module([read_original_function(module, mutant_name)]).code.strip() - mutant_code = cst.Module([read_mutant_function(module, mutant_name)]).code.strip() - - path = str(path) # difflib requires str, not Path - return '\n'.join([ - line - for line in unified_diff(orig_code.split('\n'), mutant_code.split('\n'), fromfile=path, tofile=path, lineterm='') - ]) - - -@cli.command() -@click.argument('mutant_name') -def show(mutant_name): - ensure_config_loaded() - print(get_diff_for_mutant(mutant_name)) - return - - -@cli.command() -@click.argument('mutant_name') -def apply(mutant_name): - # try: - ensure_config_loaded() - apply_mutant(mutant_name) - # except FileNotFoundError as e: - # print(e) - - -def apply_mutant(mutant_name): - path = find_mutant(mutant_name).path - - orig_function_name, class_name = orig_function_and_class_names_from_key(mutant_name) - orig_function_name = orig_function_name.rpartition('.')[-1] - - orig_module = read_orig_module(path) - mutants_module = read_mutants_module(path) - - mutant_function = read_mutant_function(mutants_module, mutant_name) - mutant_function = mutant_function.with_changes(name=cst.Name(orig_function_name)) - - original_function = find_top_level_function_or_method(orig_module, orig_function_name) - if not original_function: - raise FileNotFoundError(f'Could not apply mutant {mutant_name}') - - new_module: cst.Module = orig_module.deep_replace(original_function, mutant_function) # type: ignore - - with open(path, 'w') as f: - f.write(new_module.code) - - -@cli.command() -@click.option("--show-killed", is_flag=True, default=False, help="Display killed mutants.") -def browse(show_killed): - ensure_config_loaded() - - from textual.app import App - from textual.containers import Container - from textual.widgets import Footer - from textual.widgets import DataTable - from textual.widgets import Static - from textual.widget import Widget - from rich.syntax import Syntax - - class ResultBrowser(App): - loading_id = None - CSS_PATH = "result_browser_layout.tcss" - BINDINGS = [ - ("q", "quit()", "Quit"), - ("r", "retest_mutant()", "Retest mutant"), - ("f", "retest_function()", "Retest function"), - ("m", "retest_module()", "Retest module"), - ("a", "apply_mutant()", "Apply mutant to disk"), - ("t", "view_tests()", "View tests for mutant"), - ] - - columns = [ - ('path', 'Path'), - ] + [ - (status, Text(emoji, justify='right')) - for status, emoji in emoji_by_status.items() - ] - - cursor_type = 'row' - source_file_mutation_data_and_stat_by_path: dict[str, tuple[SourceFileMutationData, Stat]] = {} - - def compose(self): - with Container(classes='container'): - yield DataTable(id='files') - yield DataTable(id='mutants') - with Widget(id="diff_view_widget"): - yield Static(id='description') - yield Static(id='diff_view') - yield Footer() - - def on_mount(self): - # files table - # noinspection PyTypeChecker - files_table: DataTable = self.query_one('#files') - files_table.cursor_type = 'row' - for key, label in self.columns: - files_table.add_column(key=key, label=label) - - # mutants table - # noinspection PyTypeChecker - mutants_table: DataTable = self.query_one('#mutants') - mutants_table.cursor_type = 'row' - mutants_table.add_columns('name', 'status') - - self.read_data() - self.populate_files_table() - - def read_data(self): - ensure_config_loaded() - self.source_file_mutation_data_and_stat_by_path = {} - self.path_by_name = {} - - for p in walk_source_files(): - if mutmut.config.should_ignore_for_mutation(p): - continue - source_file_mutation_data = SourceFileMutationData(path=p) - source_file_mutation_data.load() - stat = collect_stat(source_file_mutation_data) - - self.source_file_mutation_data_and_stat_by_path[str(p)] = source_file_mutation_data, stat - for name in source_file_mutation_data.exit_code_by_key: - self.path_by_name[name] = p - - def populate_files_table(self): - # noinspection PyTypeChecker - files_table: DataTable = self.query_one('#files') - # TODO: restore selection - selected_row = files_table.cursor_row - files_table.clear() - - for p, (source_file_mutation_data, stat) in sorted(self.source_file_mutation_data_and_stat_by_path.items()): - row = [p] + [ - Text(str(getattr(stat, k.replace(' ', '_'))), justify="right") - for k, _ in self.columns[1:] - ] - files_table.add_row(*row, key=str(p)) - - files_table.move_cursor(row=selected_row) - - def on_data_table_row_highlighted(self, event): - if not event.row_key or not event.row_key.value: - return - if event.data_table.id == 'files': - # noinspection PyTypeChecker - mutants_table: DataTable = self.query_one('#mutants') - mutants_table.clear() - source_file_mutation_data, stat = self.source_file_mutation_data_and_stat_by_path[event.row_key.value] - for k, v in source_file_mutation_data.exit_code_by_key.items(): - status = status_by_exit_code[v] - if status != 'killed' or show_killed: - mutants_table.add_row(k, emoji_by_status[status], key=k) - else: - assert event.data_table.id == 'mutants' - # noinspection PyTypeChecker - description_view: Static = self.query_one('#description') - mutant_name = event.row_key.value - self.loading_id = mutant_name - path = self.path_by_name.get(mutant_name) - source_file_mutation_data, stat = self.source_file_mutation_data_and_stat_by_path[str(path)] - - exit_code = source_file_mutation_data.exit_code_by_key[mutant_name] - status = status_by_exit_code[exit_code] - estimated_duration = source_file_mutation_data.estimated_time_of_tests_by_mutant.get(mutant_name, '?') - duration = source_file_mutation_data.durations_by_key.get(mutant_name, '?') - - view_tests_description = f'(press t to view tests executed for this mutant)' - - match status: - case 'killed': - description = f'Killed ({exit_code=}): Mutant caused a test to fail 🎉' - case 'survived': - description = f'Survived ({exit_code=}): No test detected this mutant. {view_tests_description}' - case 'skipped': - description = f'Skipped ({exit_code=})' - case 'check was interrupted by user': - description = f'User interrupted ({exit_code=})' - case 'timeout': - description = (f'Timeout ({exit_code=}): Timed out because tests did not finish within {duration:.3f} seconds. ' - f'Tests without mutation took {estimated_duration:.3f} seconds. {view_tests_description}') - case 'no tests': - description = f'Untested ({exit_code=}): Skipped because selected tests do not execute this code.' - case 'segfault': - description = f'Segfault ({exit_code=}): Running pytest with this mutant segfaulted.' - case 'suspicious': - description = f'Unknown ({exit_code=}): Running pytest with this mutant resulted in an unknown exit code.' - case 'not checked': - description = 'Not checked in the last mutmut run.' - case _: - description = f'Unknown status ({exit_code=}, {status=})' - description_view.update(f'\n {description}\n') - - diff_view: Static = self.query_one('#diff_view') - diff_view.update('') - - def load_thread(): - ensure_config_loaded() - try: - d = get_diff_for_mutant(event.row_key.value, path=path) - if event.row_key.value == self.loading_id: - diff_view.update(Syntax(d, "diff")) - except Exception as e: - diff_view.update(f"<{type(e)} {e}>") - - t = Thread(target=load_thread) - t.start() - - def retest(self, pattern): - self._run_subprocess_command('run', [pattern]) - - def view_tests(self, mutant_name: str): - self._run_subprocess_command('tests-for-mutant', [mutant_name]) - - def _run_subprocess_command(self, command: str, args: list[str]): - with self.suspend(): - browse_index = sys.argv.index('browse') - initial_args = sys.argv[:browse_index] - subprocess_args = [sys.executable, *initial_args, command, *args] - print('>', *subprocess_args) - subprocess.run(subprocess_args) - input('press enter to return to browser') - - self.read_data() - self.populate_files_table() - - def get_mutant_name_from_selection(self): - # noinspection PyTypeChecker - mutants_table: DataTable = self.query_one('#mutants') - if mutants_table.cursor_row is None: - return - - return mutants_table.get_row_at(mutants_table.cursor_row)[0] - - def action_retest_mutant(self): - self.retest(self.get_mutant_name_from_selection()) - - def action_retest_function(self): - self.retest(self.get_mutant_name_from_selection().rpartition('__mutmut_')[0] + '__mutmut_*') - - def action_retest_module(self): - self.retest(self.get_mutant_name_from_selection().rpartition('.')[0] + '.*') - - def action_apply_mutant(self): - ensure_config_loaded() - # noinspection PyTypeChecker - mutants_table: DataTable = self.query_one('#mutants') - if mutants_table.cursor_row is None: - return - apply_mutant(mutants_table.get_row_at(mutants_table.cursor_row)[0]) - - def action_view_tests(self): - self.view_tests(self.get_mutant_name_from_selection()) - - ResultBrowser().run() - - -if __name__ == '__main__': - cli() diff --git a/src/mutmut/trampoline_templates.py b/src/mutmut/trampoline_templates.py deleted file mode 100644 index ad4cc90a..00000000 --- a/src/mutmut/trampoline_templates.py +++ /dev/null @@ -1,73 +0,0 @@ -CLASS_NAME_SEPARATOR = 'ǁ' - -def build_trampoline(*, orig_name, mutants, class_name): - mangled_name = mangle_function_name(name=orig_name, class_name=class_name) - - mutants_dict = f'{mangled_name}__mutmut_mutants : ClassVar[MutantDict] = {{\n' + ', \n '.join(f'{repr(m)}: {m}' for m in mutants) + '\n}' - access_prefix = '' - access_suffix = '' - self_arg = '' - if class_name is not None: - access_prefix = f'object.__getattribute__(self, "' - access_suffix = '")' - self_arg = ', self' - - trampoline_name = '_mutmut_trampoline' - - return f""" -{mutants_dict} - -def {orig_name}({'self, ' if class_name is not None else ''}*args, **kwargs): - result = {trampoline_name}({access_prefix}{mangled_name}__mutmut_orig{access_suffix}, {access_prefix}{mangled_name}__mutmut_mutants{access_suffix}, args, kwargs{self_arg}) - return result - -{orig_name}.__signature__ = _mutmut_signature({mangled_name}__mutmut_orig) -{mangled_name}__mutmut_orig.__name__ = '{mangled_name}' -""" - -def mangle_function_name(*, name, class_name): - assert CLASS_NAME_SEPARATOR not in name - if class_name: - assert CLASS_NAME_SEPARATOR not in class_name - prefix = f'x{CLASS_NAME_SEPARATOR}{class_name}{CLASS_NAME_SEPARATOR}' - else: - prefix = 'x_' - return f'{prefix}{name}' - -# noinspection PyUnresolvedReferences -# language=python -trampoline_impl = """ -from inspect import signature as _mutmut_signature -from typing import Annotated -from typing import Callable -from typing import ClassVar - - -MutantDict = Annotated[dict[str, Callable], "Mutant"] - - -def _mutmut_trampoline(orig, mutants, call_args, call_kwargs, self_arg = None): - \"""Forward call to original or mutated function, depending on the environment\""" - import os - mutant_under_test = os.environ['MUTANT_UNDER_TEST'] - if mutant_under_test == 'fail': - from mutmut.__main__ import MutmutProgrammaticFailException - raise MutmutProgrammaticFailException('Failed programmatically') - elif mutant_under_test == 'stats': - from mutmut.__main__ import record_trampoline_hit - record_trampoline_hit(orig.__module__ + '.' + orig.__name__) - result = orig(*call_args, **call_kwargs) - return result - prefix = orig.__module__ + '.' + orig.__name__ + '__mutmut_' - if not mutant_under_test.startswith(prefix): - result = orig(*call_args, **call_kwargs) - return result - mutant_name = mutant_under_test.rpartition('.')[-1] - if self_arg is not None: - # call to a class method where self is not bound - result = mutants[mutant_name](self_arg, *call_args, **call_kwargs) - else: - result = mutants[mutant_name](*call_args, **call_kwargs) - return result - -""" diff --git a/src/nootnoot/__init__.py b/src/nootnoot/__init__.py new file mode 100644 index 00000000..230b34bd --- /dev/null +++ b/src/nootnoot/__init__.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import importlib.metadata + +from nootnoot.app.state import NootNootState, get_state, set_state + +__version__ = importlib.metadata.version("nootnoot") + + +def add_stat(name: str) -> None: + get_state().add_stat(name) + + +def clear_stats() -> None: + get_state().clear_stats() + + +def iter_stats() -> set[str]: + return get_state().iter_stats() + + +def consume_stats() -> set[str]: + return get_state().consume_stats() + + +def set_covered_lines(covered_lines: dict[str, set[int]] | None) -> None: + get_state().covered_lines = covered_lines + + +def get_covered_lines() -> dict[str, set[int]] | None: + return get_state().covered_lines + + +def _reset_globals(): + set_state(NootNootState()) diff --git a/src/nootnoot/__main__.py b/src/nootnoot/__main__.py new file mode 100644 index 00000000..f77ab754 --- /dev/null +++ b/src/nootnoot/__main__.py @@ -0,0 +1,11 @@ +"""Entry-point module, invoked with ``python -m nootnoot``. + +Why does this file exist, and why __main__? For more info, read: +- https://www.python.org/dev/peps/pep-0338/ +- https://docs.python.org/3/using/cmdline.html#cmdoption-m +""" + +from nootnoot.cli.root import cli + +if __name__ == "__main__": + cli() diff --git a/src/nootnoot/app/__init__.py b/src/nootnoot/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/mutmut/code_coverage.py b/src/nootnoot/app/code_coverage.py similarity index 58% rename from src/mutmut/code_coverage.py rename to src/nootnoot/app/code_coverage.py index 8878aa3c..da0386c8 100644 --- a/src/mutmut/code_coverage.py +++ b/src/nootnoot/app/code_coverage.py @@ -1,35 +1,45 @@ -import coverage import importlib import sys +from collections.abc import Iterable from pathlib import Path -import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from nootnoot.app.runners import TestRunner + +import coverage -# Returns a set of lines that are covered in this file gvein the covered_lines dict -# returned by gather_coverage -# None means it's not enabled, set() means no lines are covered -def get_covered_lines_for_file(filename: str, covered_lines: dict[str, set[int]]): +# Returns a set of lines that are covered in this file given the covered_lines dict +# returned by gather_coverage. +# None means it's not enabled, set() means no lines are covered. +def get_covered_lines_for_file( + filename: str | Path, + covered_lines: dict[str, set[int]] | None, +) -> set[int] | None: if covered_lines is None or filename is None: return None - abs_filename = str((Path('mutants') / filename).absolute()) - lines = None - if abs_filename in covered_lines: - lines = covered_lines[abs_filename] + abs_filename = str((Path("mutants") / filename).absolute()) + lines = covered_lines.get(abs_filename) + + return lines or set() - return lines or set() # Gathers coverage for the given source files and # Returns a dict of filenames to sets of lines that are covered -# Since this is run on the source files before we create mutations, +# Since this is run on the source files before we create mutations, # we need to unload any modules that get loaded during the test run -def gather_coverage(runner, source_files): +def gather_coverage( + runner: "TestRunner", + source_files: Iterable[Path | str], +) -> dict[str, set[int]]: # We want to unload any python modules that get loaded # because we plan to mutate them and want them to be reloaded modules = dict(sys.modules) - mutants_path = Path('mutants') - + mutants_path = Path("mutants") + # Run the tests and gather coverage cov = coverage.Coverage(source=[str(mutants_path.absolute())], data_file=None) with cov.collect(): @@ -39,7 +49,7 @@ def gather_coverage(runner, source_files): # Build mapping of filenames to covered lines # The CoverageData object is a wrapper around sqlite, and this # will make it more efficient to access the data - covered_lines = {} + covered_lines: dict[str, set[int]] = {} coverage_data = cov.get_data() for filename in source_files: @@ -47,18 +57,21 @@ def gather_coverage(runner, source_files): lines = coverage_data.lines(abs_filename) if lines is None: # file was not imported during test run, e.g. because test selection excluded this file - lines = [] - covered_lines[abs_filename] = list(lines) + covered_lines[abs_filename] = set() + else: + # REVIEW: use set for O(1) membership in MutationVisitor covered-lines filtering + covered_lines[abs_filename] = set(lines) _unload_modules_not_in(modules) return covered_lines + # Unloads modules that are not in the 'modules' list def _unload_modules_not_in(modules): - for name in list(sys.modules): - if name == 'mutmut.code_coverage': + for name in list(sys.modules): + if name == "nootnoot.app.code_coverage": continue if name not in modules: sys.modules.pop(name, None) - importlib.invalidate_caches() \ No newline at end of file + importlib.invalidate_caches() diff --git a/src/nootnoot/app/config.py b/src/nootnoot/app/config.py new file mode 100644 index 00000000..175a45f3 --- /dev/null +++ b/src/nootnoot/app/config.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import fnmatch +import tomllib +from configparser import ConfigParser, NoOptionError, NoSectionError +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, TypeVar, cast + +if TYPE_CHECKING: + from collections.abc import Callable + + from nootnoot.app.state import NootNootState + +T = TypeVar("T") + + +@dataclass +class Config: + also_copy: list[Path] + do_not_mutate: list[str] + max_stack_depth: int + debug: bool + paths_to_mutate: list[Path] + pytest_add_cli_args: list[str] + pytest_add_cli_args_test_selection: list[str] + tests_dir: list[str] + mutate_only_covered_lines: bool + + def should_ignore_for_mutation(self, path: Path | str) -> bool: + checked_path = str(path) + if not checked_path.endswith(".py"): + return True + return any(fnmatch.fnmatch(checked_path, pattern) for pattern in self.do_not_mutate) + + +DEFAULT_DEBUG = False +DEFAULT_MUTATE_ONLY_COVERED_LINES = False + + +def guess_paths_to_mutate() -> list[Path]: + """Guess the path to source code to mutate.""" + this_dir = Path.cwd().name + candidate_dirs = [ + "lib", + "src", + this_dir, + this_dir.replace("-", "_"), + this_dir.replace(" ", "_"), + this_dir.replace("-", ""), + this_dir.replace(" ", ""), + ] + seen: set[str] = set() + for candidate in candidate_dirs: + if candidate in seen: + continue + seen.add(candidate) + if Path(candidate).is_dir(): + return [Path(candidate)] + + file_candidate = Path(f"{this_dir}.py") + if file_candidate.is_file(): + return [file_candidate] + + msg = ( + "Could not figure out where the code to mutate is. " + 'Please specify it by adding "paths_to_mutate=code_dir" in setup.cfg to the [nootnoot] section.' + ) + raise FileNotFoundError(msg) + + +def config_reader() -> Callable[[str, T], T]: + path = Path("pyproject.toml") + if path.exists(): + data = tomllib.loads(path.read_text("utf-8")) + + try: + config = data["tool"]["nootnoot"] + except KeyError: + pass + else: + + def reader(key: str, default: T) -> T: + try: + result: Any = config[key] + except KeyError: + return default + return cast("T", result) + + return reader + + config_parser = ConfigParser() + config_parser.read("setup.cfg") + + def reader(key: str, default: T) -> T: + try: + result = config_parser.get("nootnoot", key) + except (NoOptionError, NoSectionError): + return default + if isinstance(default, list): + result = [x for x in result.split("\n") if x] if "\n" in result else [result] + elif isinstance(default, bool): + result = result.lower() in {"1", "t", "true"} + elif isinstance(default, int): + result = int(result) + return cast("T", result) + + return reader + + +def ensure_config_loaded(state: NootNootState) -> None: + if state.config is None: + state.config = load_config() + + +def get_config(state: NootNootState) -> Config: + ensure_config_loaded(state) + if state.config is None: + msg = "nootnoot config must be loaded before accessing it" + raise RuntimeError(msg) + return state.config + + +def load_config() -> Config: + reader: Any = config_reader() + + paths_from_config = [Path(y) for y in reader("paths_to_mutate", [])] + + return Config( + do_not_mutate=reader("do_not_mutate", []), + also_copy=[Path(y) for y in reader("also_copy", [])] + + [ + Path("tests/"), + Path("test/"), + Path("setup.cfg"), + Path("pyproject.toml"), + ] + + list(Path().glob("test*.py")), + max_stack_depth=reader("max_stack_depth", -1), + debug=reader("debug", DEFAULT_DEBUG), + mutate_only_covered_lines=reader("mutate_only_covered_lines", DEFAULT_MUTATE_ONLY_COVERED_LINES), + paths_to_mutate=paths_from_config or guess_paths_to_mutate(), + tests_dir=reader("tests_dir", []), + pytest_add_cli_args=reader("pytest_add_cli_args", []), + pytest_add_cli_args_test_selection=reader("pytest_add_cli_args_test_selection", []), + ) diff --git a/src/nootnoot/app/events.py b/src/nootnoot/app/events.py new file mode 100644 index 00000000..45c832a0 --- /dev/null +++ b/src/nootnoot/app/events.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Protocol + + +@dataclass(frozen=True) +class RunEvent: + event: str + data: dict[str, Any] + + def as_dict(self) -> dict[str, Any]: + return {"event": self.event, "data": dict(self.data)} + + +class EventSink(Protocol): + def emit(self, event: RunEvent) -> None: ... + + +class ListEventSink: + def __init__(self) -> None: + self.events: list[RunEvent] = [] + + def emit(self, event: RunEvent) -> None: + self.events.append(event) + + +def emit_event(sink: EventSink | None, event: str, data: dict[str, Any]) -> None: + if sink is None: + return + sink.emit(RunEvent(event=event, data=data)) diff --git a/src/nootnoot/app/meta.py b/src/nootnoot/app/meta.py new file mode 100644 index 00000000..03bb3daf --- /dev/null +++ b/src/nootnoot/app/meta.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import os +from datetime import UTC, datetime +from multiprocessing import Lock +from pathlib import Path +from signal import SIGTERM + +from nootnoot.app.persistence import MetaPayload, load_meta, save_meta + + +def _utcnow() -> datetime: + return datetime.now(tz=UTC) + + +START_TIMES_BY_PID_LOCK = Lock() + + +class SourceFileMutationData: + def __init__(self, *, path: Path): + self.estimated_time_of_tests_by_mutant: dict[str, float] = {} + self.path = path + self.meta_path = Path("mutants") / (str(path) + ".meta") + self.key_by_pid: dict[int, str] = {} + self.exit_code_by_key: dict[str, int | None] = {} + self.durations_by_key: dict[str, float] = {} + self.start_time_by_pid: dict[int, datetime] = {} + + def load(self, *, debug: bool = False) -> None: + payload = load_meta(self.meta_path, debug=debug) + if payload is None: + return + self.exit_code_by_key = payload.exit_code_by_key + self.durations_by_key = payload.durations_by_key + self.estimated_time_of_tests_by_mutant = payload.estimated_durations_by_key + + def register_pid(self, *, pid: int, key: str) -> None: + self.key_by_pid[pid] = key + with START_TIMES_BY_PID_LOCK: + self.start_time_by_pid[pid] = _utcnow() + + def register_result(self, *, pid: int, exit_code: int | None) -> None: + key = self.key_by_pid.get(pid) + if key not in self.exit_code_by_key: + msg = f"Unknown mutant key for pid {pid}" + raise KeyError(msg) + self.exit_code_by_key[key] = exit_code + self.durations_by_key[key] = (_utcnow() - self.start_time_by_pid[pid]).total_seconds() + del self.key_by_pid[pid] + with START_TIMES_BY_PID_LOCK: + del self.start_time_by_pid[pid] + self.save() + + def stop_children(self) -> None: + for pid in self.key_by_pid: + os.kill(pid, SIGTERM) + + def save(self) -> None: + save_meta( + self.meta_path, + MetaPayload( + exit_code_by_key=self.exit_code_by_key, + durations_by_key=self.durations_by_key, + estimated_durations_by_key=self.estimated_time_of_tests_by_mutant, + ), + ) diff --git a/src/nootnoot/app/mutant_env.py b/src/nootnoot/app/mutant_env.py new file mode 100644 index 00000000..ae28130a --- /dev/null +++ b/src/nootnoot/app/mutant_env.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import os +from contextlib import contextmanager +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterator + +MUTANT_UNDER_TEST_ENV_VAR = "MUTANT_UNDER_TEST" + + +def set_mutant_under_test(value: str) -> None: + """Set the mutation-test selector for the current process. + + This is intentionally environment-based so it naturally propagates to: + - async tasks (same process) + - threads (same process) + - child processes (inherited env by default) + """ + os.environ[MUTANT_UNDER_TEST_ENV_VAR] = value + + +def clear_mutant_under_test() -> None: + os.environ.pop(MUTANT_UNDER_TEST_ENV_VAR, None) + + +@contextmanager +def mutant_under_test(value: str) -> Iterator[None]: + """Temporarily set MUTANT_UNDER_TEST and restore the previous value. + + Prefer this for scoped operations (stats, clean runs, forced-fail runs) to keep + nootnoot behavior deterministic and test-friendly. + """ + old = os.environ.get(MUTANT_UNDER_TEST_ENV_VAR) + os.environ[MUTANT_UNDER_TEST_ENV_VAR] = value + try: + yield + finally: + if old is None: + os.environ.pop(MUTANT_UNDER_TEST_ENV_VAR, None) + else: + os.environ[MUTANT_UNDER_TEST_ENV_VAR] = old diff --git a/src/nootnoot/app/mutation.py b/src/nootnoot/app/mutation.py new file mode 100644 index 00000000..5fbd5948 --- /dev/null +++ b/src/nootnoot/app/mutation.py @@ -0,0 +1,521 @@ +from __future__ import annotations + +import ast +import fnmatch +import inspect +import os +import shutil +import sys +import warnings +from collections import defaultdict +from dataclasses import dataclass +from datetime import UTC, datetime +from difflib import unified_diff +from multiprocessing import Pool +from os import walk +from pathlib import Path +from typing import TYPE_CHECKING, TextIO, cast + +import libcst as cst + +from nootnoot.app.code_coverage import gather_coverage, get_covered_lines_for_file +from nootnoot.app.config import get_config +from nootnoot.app.meta import SourceFileMutationData +from nootnoot.app.runners import PytestRunner +from nootnoot.app.state import NootNootState, get_state +from nootnoot.core import trampoline_runtime +from nootnoot.core.file_mutation import mutate_file_contents +from nootnoot.core.trampoline_templates import CLASS_NAME_SEPARATOR + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator, Sequence +else: + Iterable = object + Iterator = object + Sequence = object + +status_by_exit_code = defaultdict( + lambda: "suspicious", + { + 1: "killed", + 3: "killed", + 0: "survived", + 5: "no tests", + 2: "check was interrupted by user", + None: "not checked", + 33: "no tests", + 34: "skipped", + 35: "suspicious", + 36: "timeout", + -24: "timeout", + 24: "timeout", + 152: "timeout", + 255: "timeout", + -11: "segfault", + -9: "segfault", + }, +) + +emoji_by_status = { + "survived": "🙁", + "no tests": "🫥", + "timeout": "⏰", + "suspicious": "🤔", + "skipped": "🔇", + "check was interrupted by user": "🛑", + "not checked": "?", + "killed": "🎉", + "segfault": "💥", +} + +exit_code_to_emoji = {exit_code: emoji_by_status[status] for exit_code, status in status_by_exit_code.items()} + + +def utcnow() -> datetime: + return datetime.now(tz=UTC) + + +class InvalidGeneratedSyntaxException(Exception): + def __init__(self, file: Path | str) -> None: + super().__init__( + f"NootNoot generated invalid python syntax for {file}. " + "If the original file has valid python syntax, please file an issue " + "with a minimal reproducible example file." + ) + + +def record_trampoline_hit(name: str, state: NootNootState | None = None) -> None: + if state is None: + trampoline_runtime.record_trampoline_hit(name) + return + if name.startswith("src."): + msg = "Failed trampoline hit. Module name starts with `src.`, which is invalid" + raise ValueError(msg) + config = get_config(state) + if config.max_stack_depth != -1: + f = inspect.currentframe() + c = config.max_stack_depth + while c and f: + filename = f.f_code.co_filename + if "pytest" in filename or "hammett" in filename or "unittest" in filename: + break + f = f.f_back + c -= 1 + + if not c: + return + + state.add_stat(name) + + +NootNootProgrammaticFailException = trampoline_runtime.NootNootProgrammaticFailException + + +def _get_max_stack_depth() -> int: + state = get_state() + config = get_config(state) + return config.max_stack_depth + + +def _add_stat(name: str) -> None: + state = get_state() + state.add_stat(name) + + +trampoline_runtime.register_trampoline_hooks( + get_max_stack_depth=_get_max_stack_depth, + add_stat=_add_stat, +) + + +def walk_all_files(state: NootNootState) -> Iterator[tuple[str, str]]: + config = get_config(state) + for path in config.paths_to_mutate: + if not path.is_dir() and path.is_file(): + yield "", str(path) + continue + for root, _dirs, files in walk(path): + for filename in files: + yield root, filename + + +def walk_source_files(state: NootNootState) -> Iterator[Path]: + for root, filename in walk_all_files(state): + if filename.endswith(".py"): + yield Path(root) / filename + + +@dataclass +class FileMutationResult: + warnings: list[Warning] + error: Exception | None = None + + +@dataclass +class Stat: + not_checked: int + killed: int + survived: int + total: int + no_tests: int + skipped: int + suspicious: int + timeout: int + check_was_interrupted_by_user: int + segfault: int + + +def create_mutants(max_children: int, state: NootNootState) -> None: + with Pool(processes=max_children) as p: + for result in p.imap_unordered( + _create_file_mutants_with_state, + [(path, state) for path in walk_source_files(state)], + ): + for warning in result.warnings: + warnings.warn(warning, stacklevel=2) + if result.error: + raise result.error + + +def _create_file_mutants_with_state(args: tuple[Path, NootNootState]) -> FileMutationResult: + path, state = args + return create_file_mutants(path, state) + + +def create_file_mutants(path: Path, state: NootNootState) -> FileMutationResult: + try: + print(path, file=sys.stderr) + output_path = Path("mutants") / path + Path(output_path.parent).mkdir(exist_ok=True, parents=True) + + config = get_config(state) + if config.should_ignore_for_mutation(path): + shutil.copy(path, output_path) + return FileMutationResult(warnings=[]) + return create_mutants_for_file(path, output_path, state) + except Exception as e: # noqa: BLE001 + return FileMutationResult(warnings=[], error=e) + + +def copy_src_dir(state: NootNootState) -> None: + config = get_config(state) + for path in config.paths_to_mutate: + output_path: Path = Path("mutants") / path + if path.is_dir(): + shutil.copytree(path, output_path, dirs_exist_ok=True) + else: + output_path.parent.mkdir(exist_ok=True, parents=True) + shutil.copyfile(path, output_path) + + +def setup_source_paths(): + source_code_paths = [Path(), Path("src"), Path("source")] + for path in source_code_paths: + mutated_path = Path("mutants") / path + if mutated_path.exists(): + sys.path.insert(0, str(mutated_path.absolute())) + + for path in source_code_paths: + for i in range(len(sys.path)): + while i < len(sys.path) and Path(sys.path[i]).resolve() == path.resolve(): + del sys.path[i] + + +def store_lines_covered_by_tests(state: NootNootState) -> None: + config = get_config(state) + if config.mutate_only_covered_lines: + covered_lines = gather_coverage(PytestRunner(state), list(walk_source_files(state))) + state.covered_lines = covered_lines + + +def copy_also_copy_files(state: NootNootState) -> None: + config = get_config(state) + if not isinstance(config.also_copy, list): + msg = "config.also_copy must be a list of paths" + raise TypeError(msg) + for path_to_copy in config.also_copy: + print(" also copying", path_to_copy, file=sys.stderr) + source_path = Path(path_to_copy) + destination = Path("mutants") / source_path + if not source_path.exists(): + continue + if source_path.is_file(): + shutil.copy(source_path, destination) + else: + shutil.copytree(source_path, destination, dirs_exist_ok=True) + + +def create_mutants_for_file(filename: Path, output_path: Path, state: NootNootState) -> FileMutationResult: + input_stat = filename.stat() + warnings_list: list[Warning] = [] + + source = filename.read_text(encoding="utf-8") + + with output_path.open("w", encoding="utf-8") as out: + try: + mutant_names = write_all_mutants_to_file( + out=out, + source=source, + filename=filename, + state=state, + ) + except cst.ParserSyntaxError as e: + warnings_list.append(SyntaxWarning(f"Unsupported syntax in {filename} ({e!s}), skipping")) + out.write(source) + mutant_names = [] + + try: + ast.parse(output_path.read_text(encoding="utf-8")) + except (IndentationError, SyntaxError) as e: + invalid_syntax_error = InvalidGeneratedSyntaxException(output_path) + invalid_syntax_error.__cause__ = e + return FileMutationResult(warnings=warnings_list, error=invalid_syntax_error) + + source_file_mutation_data = SourceFileMutationData(path=filename) + module_name = strip_prefix(str(filename)[: -len(filename.suffix)].replace(os.sep, "."), prefix="src.") + + source_file_mutation_data.exit_code_by_key = { + f"{module_name}.{x}".replace(".__init__.", "."): None for x in mutant_names + } + source_file_mutation_data.save() + + os.utime(output_path, (input_stat.st_atime, input_stat.st_mtime)) + return FileMutationResult(warnings=warnings_list) + + +def write_all_mutants_to_file( + *, + out: TextIO, + source: str, + filename: str | Path, + state: NootNootState, +) -> Sequence[str]: + covered_lines = state.covered_lines + result, mutant_names = mutate_file_contents( + str(filename), + source, + get_covered_lines_for_file(filename, covered_lines), + ) + out.write(result) + + return mutant_names + + +def unused(*_: object) -> None: + return + + +def collected_test_names(state: NootNootState) -> set[str]: + return set(state.duration_by_test.keys()) + + +def strip_prefix(s: str, *, prefix: str, strict: bool = False) -> str: + if s.startswith(prefix): + return s[len(prefix) :] + if strict: + msg = f"String '{s}' does not start with prefix '{prefix}'" + raise ValueError(msg) + return s + + +def mangled_name_from_mutant_name(mutant_name: str) -> str: + if "__nootnoot_" not in mutant_name: + msg = f"{mutant_name} is not a valid mutant name" + raise ValueError(msg) + return mutant_name.partition("__nootnoot_")[0] + + +def orig_function_and_class_names_from_key(mutant_name: str) -> tuple[str, str | None]: + r = mangled_name_from_mutant_name(mutant_name) + _, _, r = r.rpartition(".") + class_name = None + if CLASS_NAME_SEPARATOR in r: + class_name = r[r.index(CLASS_NAME_SEPARATOR) + 1 : r.rindex(CLASS_NAME_SEPARATOR)] + r = r[r.rindex(CLASS_NAME_SEPARATOR) + 1 :] + else: + if not r.startswith("x_"): + msg = f"Invalid mutant key: {mutant_name}" + raise ValueError(msg) + r = r[2:] + return r, class_name + + +def collect_stat(m: SourceFileMutationData) -> Stat: + r = {k.replace(" ", "_"): 0 for k in status_by_exit_code.values()} + for v in m.exit_code_by_key.values(): + r[status_by_exit_code[v].replace(" ", "_")] += 1 + return Stat( + **r, + total=sum(r.values()), + ) + + +def calculate_summary_stats(source_file_mutation_data_by_path: dict[str, SourceFileMutationData]) -> Stat: + stats = [collect_stat(x) for x in source_file_mutation_data_by_path.values()] + return Stat( + not_checked=sum(x.not_checked for x in stats), + killed=sum(x.killed for x in stats), + survived=sum(x.survived for x in stats), + total=sum(x.total for x in stats), + no_tests=sum(x.no_tests for x in stats), + skipped=sum(x.skipped for x in stats), + suspicious=sum(x.suspicious for x in stats), + timeout=sum(x.timeout for x in stats), + check_was_interrupted_by_user=sum(x.check_was_interrupted_by_user for x in stats), + segfault=sum(x.segfault for x in stats), + ) + + +def collect_source_file_mutation_data( + *, mutant_names: Iterable[str], state: NootNootState +) -> tuple[list[tuple[SourceFileMutationData, str, int | None]], dict[str, SourceFileMutationData]]: + source_file_mutation_data_by_path: dict[str, SourceFileMutationData] = {} + config = get_config(state) + + for path in walk_source_files(state): + if config.should_ignore_for_mutation(path): + continue + if path in source_file_mutation_data_by_path: + msg = f"Duplicate source file entry detected: {path}" + raise ValueError(msg) + m = SourceFileMutationData(path=path) + m.load(debug=config.debug) + source_file_mutation_data_by_path[str(path)] = m + + mutants = [ + (m, mutant_name, result) + for path, m in source_file_mutation_data_by_path.items() + for mutant_name, result in m.exit_code_by_key.items() + ] + + if mutant_names: + filtered_mutants = [ + (m, key, result) + for m, key, result in mutants + if key in mutant_names or any(fnmatch.fnmatch(key, mutant_name) for mutant_name in mutant_names) + ] + if not filtered_mutants: + msg = f"Filtered for specific mutants, but nothing matches. Filters: {mutant_names}" + raise ValueError(msg) + mutants = filtered_mutants + return mutants, source_file_mutation_data_by_path + + +def estimated_worst_case_time(state: NootNootState, mutant_name: str) -> float: + tests = state.tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), set()) + return sum(state.duration_by_test[t] for t in tests) + + +def tests_for_mutant_names(state: NootNootState, mutant_names: Iterable[str]) -> set[str]: + tests: set[str] = set() + for mutant_name in mutant_names: + if "*" in mutant_name: + for name, tests_of_this_name in state.tests_by_mangled_function_name.items(): + if fnmatch.fnmatch(name, mutant_name): + tests |= set(tests_of_this_name) + else: + tests |= set(state.tests_by_mangled_function_name[mangled_name_from_mutant_name(mutant_name)]) + return tests + + +def read_mutants_module(path: str | Path) -> cst.Module: + mutants_path = Path("mutants") / path + return cst.parse_module(mutants_path.read_text(encoding="utf-8")) + + +def read_orig_module(path: str | Path) -> cst.Module: + return cst.parse_module(Path(path).read_text(encoding="utf-8")) + + +def find_top_level_function_or_method(module: cst.Module, name: str) -> cst.FunctionDef | None: + name = name.rsplit(".", maxsplit=1)[-1] + for child in module.body: + if isinstance(child, cst.FunctionDef) and child.name.value == name: + return child + if isinstance(child, cst.ClassDef) and isinstance(child.body, cst.IndentedBlock): + for method in child.body.body: + if isinstance(method, cst.FunctionDef) and method.name.value == name: + return method + + return None + + +def read_original_function(module: cst.Module, mutant_name: str) -> cst.FunctionDef: + orig_function_name, _ = orig_function_and_class_names_from_key(mutant_name) + orig_name = mangled_name_from_mutant_name(mutant_name) + "__nootnoot_orig" + + result = find_top_level_function_or_method(module, orig_name) + if not result: + msg = f'Could not find original function "{orig_function_name}"' + raise FileNotFoundError(msg) + return result.with_changes(name=cst.Name(orig_function_name)) + + +def read_mutant_function(module: cst.Module, mutant_name: str) -> cst.FunctionDef: + orig_function_name, _ = orig_function_and_class_names_from_key(mutant_name) + + result = find_top_level_function_or_method(module, mutant_name) + if not result: + msg = f'Could not find original function "{orig_function_name}"' + raise FileNotFoundError(msg) + return result.with_changes(name=cst.Name(orig_function_name)) + + +def find_mutant(state: NootNootState, mutant_name: str) -> SourceFileMutationData: + config = get_config(state) + for path in walk_source_files(state): + if config.should_ignore_for_mutation(path): + continue + + m = SourceFileMutationData(path=path) + m.load(debug=config.debug) + if mutant_name in m.exit_code_by_key: + return m + + msg = f"Could not find mutant {mutant_name}" + raise FileNotFoundError(msg) + + +def get_diff_for_mutant( + state: NootNootState, + mutant_name: str, + source: str | None = None, + path: str | Path | None = None, +) -> str: + if path is None: + m = find_mutant(state, mutant_name) + path = m.path + + module = read_mutants_module(path) if source is None else cst.parse_module(source) + orig_code = cst.Module([read_original_function(module, mutant_name)]).code.strip() + mutant_code = cst.Module([read_mutant_function(module, mutant_name)]).code.strip() + + path = str(path) + return "\n".join([ + line + for line in unified_diff( + orig_code.split("\n"), mutant_code.split("\n"), fromfile=path, tofile=path, lineterm="" + ) + ]) + + +def apply_mutant(state: NootNootState, mutant_name: str) -> None: + path = find_mutant(state, mutant_name).path + + orig_function_name, _class_name = orig_function_and_class_names_from_key(mutant_name) + orig_function_name = orig_function_name.rpartition(".")[-1] + + orig_module = read_orig_module(path) + mutants_module = read_mutants_module(path) + + mutant_function = read_mutant_function(mutants_module, mutant_name) + mutant_function = mutant_function.with_changes(name=cst.Name(orig_function_name)) + + original_function = find_top_level_function_or_method(orig_module, orig_function_name) + if not original_function: + msg = f"Could not apply mutant {mutant_name}" + raise FileNotFoundError(msg) + + new_module = cast("cst.Module", orig_module.deep_replace(original_function, mutant_function)) + + Path(path).write_text(new_module.code, encoding="utf-8") diff --git a/src/nootnoot/app/persistence.py b/src/nootnoot/app/persistence.py new file mode 100644 index 00000000..08929956 --- /dev/null +++ b/src/nootnoot/app/persistence.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import json +import os +import warnings +from collections import defaultdict +from dataclasses import dataclass +from json import JSONDecodeError +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from nootnoot.app.state import NootNootState + +SCHEMA_VERSION = 1 + + +@dataclass +class MetaPayload: + exit_code_by_key: dict[str, int | None] + durations_by_key: dict[str, float] + estimated_durations_by_key: dict[str, float] + + +def _warn_debug(*, debug: bool, message: str) -> None: + if debug: + warnings.warn(message, stacklevel=2) + + +def _read_json(path: Path) -> dict[str, Any] | None: + try: + with path.open(encoding="utf-8") as file: + return json.load(file) + except (FileNotFoundError, JSONDecodeError): + return None + + +def _write_json_atomic(path: Path, data: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_name(path.name + ".tmp") + with tmp_path.open("w", encoding="utf-8") as file: + json.dump(data, file, indent=4) + file.flush() + os.fsync(file.fileno()) + Path(tmp_path).replace(path) + + +def _pop_schema_version(data: dict[str, Any], *, path: Path, debug: bool) -> int: + schema_version = int(data.pop("schema_version", 0)) + if schema_version > SCHEMA_VERSION: + _warn_debug( + debug=debug, + message=f"{path} schema_version {schema_version} is newer than supported {SCHEMA_VERSION}", + ) + return schema_version + + +def _warn_on_unknown_keys(data: dict[str, Any], *, path: Path, debug: bool) -> None: + if not data: + return + unexpected = ", ".join(sorted(data.keys())) + _warn_debug(debug=debug, message=f"{path} contains unexpected keys: {unexpected}") + + +def load_meta(path: Path, *, debug: bool) -> MetaPayload | None: + data = _read_json(path) + if data is None: + return None + _pop_schema_version(data, path=path, debug=debug) + try: + exit_code_by_key = data.pop("exit_code_by_key") + durations_by_key = data.pop("durations_by_key") + estimated_durations_by_key = data.pop("estimated_durations_by_key") + except KeyError as exc: + msg = f"Meta file {path} is missing required keys" + raise ValueError(msg) from exc + _warn_on_unknown_keys(data, path=path, debug=debug) + return MetaPayload( + exit_code_by_key=exit_code_by_key, + durations_by_key=durations_by_key, + estimated_durations_by_key=estimated_durations_by_key, + ) + + +def save_meta(path: Path, payload: MetaPayload) -> None: + _write_json_atomic( + path, + dict( + schema_version=SCHEMA_VERSION, + exit_code_by_key=payload.exit_code_by_key, + durations_by_key=payload.durations_by_key, + estimated_durations_by_key=payload.estimated_durations_by_key, + ), + ) + + +def load_stats(state: NootNootState) -> bool: + data = _read_json(Path("mutants/nootnoot-stats.json")) + if data is None: + return False + debug = state.config is not None and state.config.debug + _pop_schema_version(data, path=Path("mutants/nootnoot-stats.json"), debug=debug) + try: + tests_by_mangled_function_name = data.pop("tests_by_mangled_function_name") + duration_by_test = data.pop("duration_by_test") + stats_time = data.pop("stats_time") + except KeyError as exc: + msg = "Stats file is missing required keys" + raise ValueError(msg) from exc + _warn_on_unknown_keys(data, path=Path("mutants/nootnoot-stats.json"), debug=debug) + for k, v in tests_by_mangled_function_name.items(): + state.tests_by_mangled_function_name[k] |= set(v) + state.duration_by_test = defaultdict(float, duration_by_test) + state.stats_time = stats_time + return True + + +def save_stats(state: NootNootState) -> None: + _write_json_atomic( + Path("mutants/nootnoot-stats.json"), + dict( + schema_version=SCHEMA_VERSION, + tests_by_mangled_function_name={ + k: list(v) for k, v in state.tests_by_mangled_function_name.items() + }, + duration_by_test=state.duration_by_test, + stats_time=state.stats_time, + ), + ) diff --git a/src/nootnoot/app/reporting.py b/src/nootnoot/app/reporting.py new file mode 100644 index 00000000..49f1c1fe --- /dev/null +++ b/src/nootnoot/app/reporting.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from nootnoot.app.events import RunEvent + +REPORT_SCHEMA_VERSION = 1 + + +@dataclass(frozen=True) +class RunReport: + summary: dict[str, int] + mutants: list[dict[str, Any]] + events: list[RunEvent] + + +def render_json_report(report: RunReport) -> str: + payload = dict( + schema_version=REPORT_SCHEMA_VERSION, + summary=dict(report.summary), + mutants=list(report.mutants), + events=[event.as_dict() for event in report.events], + ) + return json.dumps(payload, indent=2, sort_keys=True) diff --git a/src/nootnoot/app/runners.py b/src/nootnoot/app/runners.py new file mode 100644 index 00000000..7d5492bd --- /dev/null +++ b/src/nootnoot/app/runners.py @@ -0,0 +1,290 @@ +from __future__ import annotations + +import importlib +import os +import sys +from abc import ABC, abstractmethod +from collections import defaultdict +from contextlib import contextmanager +from pathlib import Path +from typing import TYPE_CHECKING, Any, Protocol, cast + +from nootnoot.app.config import get_config +from nootnoot.app.persistence import save_stats + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + + from nootnoot.app.state import NootNootState + +PYTEST_USAGE_ERROR_EXIT_CODE = 4 + + +class BadTestExecutionCommandsException(Exception): + """The pytest invocation failed because the provided CLI args were invalid.""" + + +class CollectTestsFailedException(Exception): + """Pytest failed to collect tests.""" + + +class PostTestCallback(Protocol): + def __call__(self, name: str, **kwargs: object) -> None: ... + + +class HammettConfigProtocol(Protocol): + workerinput: dict[str, str] + + +class HammettModule(Protocol): + Config: HammettConfigProtocol + + def main( + self, + *, + quiet: bool, + fail_fast: bool, + disable_assert_analyze: bool, + post_test_callback: PostTestCallback | None = None, + use_cache: bool, + insert_cwd: bool, + ) -> int: ... + + def main_setup( + self, + *, + quiet: bool, + fail_fast: bool, + disable_assert_analyze: bool, + use_cache: bool, + insert_cwd: bool, + ) -> dict[str, object]: ... + + def main_run_tests(self, *, tests: Iterable[str] | None, **kwargs: object) -> int: ... + + +class TestRunner(ABC): + @abstractmethod + def run_stats(self, *, tests: Iterable[str] | None) -> int: + """Collect statistics for the provided tests.""" + + @abstractmethod + def run_forced_fail(self) -> int: + """Run the forced-fail hook for the runner.""" + + @abstractmethod + def prepare_main_test_run(self) -> None: + """Prepare the test runner before executing tests.""" + + @abstractmethod + def run_tests(self, *, mutant_name: str | None, tests: Iterable[str] | None) -> int: + """Execute the provided tests for the given mutant.""" + + @abstractmethod + def list_all_tests(self) -> ListAllTestsResult: + """Return all available tests.""" + + +@contextmanager +def change_cwd(path: Path | str) -> Iterator[None]: + old_cwd = Path(Path.cwd()).resolve() + os.chdir(path) + try: + yield + finally: + os.chdir(old_cwd) + + +def collected_test_names(state: NootNootState) -> set[str]: + return set(state.duration_by_test.keys()) + + +class ListAllTestsResult: + def __init__(self, *, ids: set[str], state: NootNootState) -> None: + if not isinstance(ids, set): + msg = f"ids must be a set, got {type(ids)}" + raise TypeError(msg) + self.ids = ids + self._state = state + + def clear_out_obsolete_test_names(self) -> None: + count_before = sum(len(v) for v in self._state.tests_by_mangled_function_name.values()) + self._state.tests_by_mangled_function_name = defaultdict( + set, + **{ + k: {test_name for test_name in test_names if test_name in self.ids} + for k, test_names in self._state.tests_by_mangled_function_name.items() + }, + ) + count_after = sum(len(v) for v in self._state.tests_by_mangled_function_name.values()) + if count_before != count_after: + print(f"Removed {count_before - count_after} obsolete test names", file=sys.stderr) + save_stats(self._state) + + def new_tests(self) -> set[str]: + return self.ids - collected_test_names(self._state) + + +def _normalized_nodeid(nodeid: str) -> str: + prefix = "mutants/" + if nodeid.startswith(prefix): + return nodeid[len(prefix) :] + return nodeid + + +class PytestRunner(TestRunner): + def __init__(self, state: NootNootState): + self._state = state + config = get_config(state) + self._pytest_add_cli_args: list[str] = list(config.pytest_add_cli_args) + self._pytest_add_cli_args_test_selection: list[str] = list(config.pytest_add_cli_args_test_selection) + + self._pytest_add_cli_args_test_selection += config.tests_dir + + def prepare_main_test_run(self) -> None: + """Pytest does not need additional preparation.""" + + def execute_pytest(self, params: list[str], **kwargs: Any) -> int: + import pytest # noqa: PLC0415 + + config = get_config(self._state) + params = ["--rootdir=.", "--tb=native", *params, *self._pytest_add_cli_args] + if config.debug: + params = ["-vv", *params] + print("python -m pytest ", " ".join([f'"{param}"' for param in params]), file=sys.stderr) + exit_code = int(pytest.main(params, **kwargs)) + if config.debug: + print(" exit code", exit_code, file=sys.stderr) + if exit_code == PYTEST_USAGE_ERROR_EXIT_CODE: + raise BadTestExecutionCommandsException(params) + return exit_code + + def run_stats(self, *, tests: Iterable[str] | None) -> int: + class StatsCollector: + def __init__(self, state: NootNootState): + self._state = state + + def pytest_runtest_logstart(self, nodeid, location): + del location + self._state.duration_by_test[nodeid] = 0 + + def pytest_runtest_teardown(self, item, nextitem): + del nextitem + for function in self._state.consume_stats(): + self._state.tests_by_mangled_function_name[function].add( + _normalized_nodeid(item.nodeid), + ) + + def pytest_runtest_makereport(self, item, call): + self._state.duration_by_test[item.nodeid] += call.duration + + stats_collector = StatsCollector(self._state) + + pytest_args = ["-x", "-q"] + if tests: + pytest_args += list(tests) + else: + pytest_args += self._pytest_add_cli_args_test_selection + with change_cwd("mutants"): + return int(self.execute_pytest(pytest_args, plugins=[stats_collector])) + + def run_tests(self, *, mutant_name: str | None, tests: Iterable[str] | None) -> int: + del mutant_name + pytest_args = ["-x", "-q", "-p", "no:randomly", "-p", "no:random-order"] + if tests: + pytest_args += list(tests) + else: + pytest_args += self._pytest_add_cli_args_test_selection + with change_cwd("mutants"): + return int(self.execute_pytest(pytest_args)) + + def run_forced_fail(self) -> int: + pytest_args = ["-x", "-q", *self._pytest_add_cli_args_test_selection] + with change_cwd("mutants"): + return int(self.execute_pytest(pytest_args)) + + def list_all_tests(self) -> ListAllTestsResult: + class TestsCollector: + def __init__(self): + self.collected_nodeids = set() + self.deselected_nodeids = set() + + def pytest_collection_modifyitems(self, items): + self.collected_nodeids |= {item.nodeid for item in items} + + def pytest_deselected(self, items): + self.deselected_nodeids |= {item.nodeid for item in items} + + collector = TestsCollector() + + pytest_args = ["-x", "-q", "--collect-only", *self._pytest_add_cli_args_test_selection] + + with change_cwd("mutants"): + exit_code = int(self.execute_pytest(pytest_args, plugins=[collector])) + if exit_code != 0: + raise CollectTestsFailedException + + selected_nodeids = collector.collected_nodeids - collector.deselected_nodeids + return ListAllTestsResult(ids=selected_nodeids, state=self._state) + + +def import_hammett() -> HammettModule: + module = importlib.import_module("hammett") + return cast("HammettModule", module) + + +class HammettRunner(TestRunner): + def __init__(self, state: NootNootState): + self._state = state + self.hammett_kwargs: dict[str, object] | None = None + + def run_stats(self, *, tests: Iterable[str] | None) -> int: + del tests + hammett = import_hammett() + + print("Running hammett stats...", file=sys.stderr) + + def post_test_callback(_name: str, **_: object) -> None: + for function in self._state.consume_stats(): + self._state.tests_by_mangled_function_name[function].add(_name) + + return hammett.main( + quiet=True, + fail_fast=True, + disable_assert_analyze=True, + post_test_callback=cast("PostTestCallback", post_test_callback), + use_cache=False, + insert_cwd=False, + ) + + def run_forced_fail(self) -> int: # noqa: PLR6301 + hammett = import_hammett() + + return hammett.main( + quiet=True, + fail_fast=True, + disable_assert_analyze=True, + use_cache=False, + insert_cwd=False, + ) + + def prepare_main_test_run(self) -> None: + hammett = import_hammett() + + self.hammett_kwargs = hammett.main_setup( + quiet=True, + fail_fast=True, + disable_assert_analyze=True, + use_cache=False, + insert_cwd=False, + ) + + def run_tests(self, *, mutant_name: str | None, tests: Iterable[str] | None) -> int: + hammett = import_hammett() + + hammett.Config.workerinput = dict(workerinput=f"_{mutant_name}") + kwargs = self.hammett_kwargs + if kwargs is None: + msg = "Hammett runner has not been prepared" + raise RuntimeError(msg) + return hammett.main_run_tests(**kwargs, tests=tests) diff --git a/src/nootnoot/app/state.py b/src/nootnoot/app/state.py new file mode 100644 index 00000000..896208c8 --- /dev/null +++ b/src/nootnoot/app/state.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from collections import defaultdict +from contextlib import contextmanager +from contextvars import ContextVar, Token +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterator + + from nootnoot.app.config import Config + + +@dataclass +class NootNootState: + duration_by_test: defaultdict[str, float] = field(default_factory=lambda: defaultdict(float)) + tests_by_mangled_function_name: defaultdict[str, set[str]] = field( + default_factory=lambda: defaultdict(set) + ) + stats_time: float | None = None + config: Config | None = None + covered_lines: dict[str, set[int]] | None = None + stats: set[str] = field(default_factory=set) + + def add_stat(self, name: str) -> None: + self.stats.add(name) + + def clear_stats(self) -> None: + self.stats.clear() + + def iter_stats(self) -> set[str]: + return set(self.stats) + + def consume_stats(self) -> set[str]: + stats = set(self.stats) + self.stats.clear() + return stats + + +_state_var: ContextVar[NootNootState | None] = ContextVar("nootnoot_state", default=None) + + +def get_state() -> NootNootState: + state = _state_var.get() + if state is None: + msg = "NootNoot state is not initialized" + raise RuntimeError(msg) + return state + + +def set_state(state: NootNootState) -> Token[NootNootState | None]: + return _state_var.set(state) + + +def reset_state(token: Token[NootNootState | None]) -> None: + _state_var.reset(token) + + +@contextmanager +def using_state(state: NootNootState) -> Iterator[NootNootState]: + token = set_state(state) + try: + yield state + finally: + reset_state(token) diff --git a/src/nootnoot/cli/__init__.py b/src/nootnoot/cli/__init__.py new file mode 100644 index 00000000..6b399061 --- /dev/null +++ b/src/nootnoot/cli/__init__.py @@ -0,0 +1,5 @@ +from .root import cli +from .run import _run +from .shared import CatchOutput, run_forced_fail_test + +__all__ = ["CatchOutput", "_run", "cli", "run_forced_fail_test"] diff --git a/src/nootnoot/cli/apply.py b/src/nootnoot/cli/apply.py new file mode 100644 index 00000000..fcc4e861 --- /dev/null +++ b/src/nootnoot/cli/apply.py @@ -0,0 +1,16 @@ +import click + +from nootnoot.app.config import ensure_config_loaded +from nootnoot.app.mutation import apply_mutant +from nootnoot.app.state import NootNootState + + +@click.command() +@click.argument("mutant_name") +@click.pass_obj +def apply(state: NootNootState, mutant_name: str) -> None: + # try: + ensure_config_loaded(state) + apply_mutant(state, mutant_name) + # except FileNotFoundError as e: + # print(e) diff --git a/src/nootnoot/cli/browse.py b/src/nootnoot/cli/browse.py new file mode 100644 index 00000000..dfcb6e2d --- /dev/null +++ b/src/nootnoot/cli/browse.py @@ -0,0 +1,266 @@ +import subprocess # noqa: S404 +import sys +from threading import Thread +from typing import Any, ClassVar, cast + +import click +from rich.text import Text + +from nootnoot.app.config import ensure_config_loaded, get_config +from nootnoot.app.meta import SourceFileMutationData +from nootnoot.app.mutation import ( + Stat, + apply_mutant, + collect_stat, + emoji_by_status, + get_diff_for_mutant, + status_by_exit_code, + unused, + walk_source_files, +) +from nootnoot.app.state import NootNootState + + +@click.command() +@click.option("--show-killed", is_flag=True, default=False, help="Display killed mutants.") +@click.pass_obj +def browse(state: NootNootState, *, show_killed: bool) -> None: + ensure_config_loaded(state) + + from rich.syntax import Syntax # noqa: PLC0415 + from textual.app import App # noqa: PLC0415 + from textual.containers import Container # noqa: PLC0415 + from textual.widget import Widget # noqa: PLC0415 + from textual.widgets import DataTable, Footer, Static # noqa: PLC0415 + + class ResultBrowser(App): + CSS_PATH: ClassVar[str] = "result_browser_layout.tcss" + BINDINGS: ClassVar[list[tuple[str, str, str]]] = [ + ("q", "quit()", "Quit"), + ("r", "retest_mutant()", "Retest mutant"), + ("f", "retest_function()", "Retest function"), + ("m", "retest_module()", "Retest module"), + ("a", "apply_mutant()", "Apply mutant to disk"), + ("t", "view_tests()", "View tests for mutant"), + ] + + columns: ClassVar[list[tuple[str, Text | str]]] = [ + ("path", "Path"), + ] + [(status, Text(emoji, justify="right")) for status, emoji in emoji_by_status.items()] + + cursor_type: ClassVar[str] = "row" + + def __init__(self, **kwargs: Any): + super().__init__(**kwargs) + self._state = state + self.loading_id: str | None = None + self.source_file_mutation_data_and_stat_by_path: dict[ + str, tuple[SourceFileMutationData, Stat] + ] = {} + self.path_by_name: dict[str, str] = {} + + def compose(self): + unused(self) + with Container(classes="container"): + yield DataTable(id="files") + yield DataTable(id="mutants") + with Widget(id="diff_view_widget"): + yield Static(id="description") + yield Static(id="diff_view") + yield Footer() + + def on_mount(self): + # files table + files_table = cast("DataTable", self.query_one("#files")) + files_table.cursor_type = "row" + for key, label in self.columns: + files_table.add_column(key=key, label=label) + + # mutants table + mutants_table = cast("DataTable", self.query_one("#mutants")) + mutants_table.cursor_type = "row" + mutants_table.add_columns("name", "status") + + self.read_data() + self.populate_files_table() + + def read_data(self): + config = get_config(self._state) + self.source_file_mutation_data_and_stat_by_path = {} + self.path_by_name = {} + + for p in walk_source_files(self._state): + if config.should_ignore_for_mutation(p): + continue + source_file_mutation_data = SourceFileMutationData(path=p) + source_file_mutation_data.load(debug=config.debug) + stat = collect_stat(source_file_mutation_data) + + path_key = str(p) + self.source_file_mutation_data_and_stat_by_path[path_key] = ( + source_file_mutation_data, + stat, + ) + for name in source_file_mutation_data.exit_code_by_key: + self.path_by_name[name] = path_key + + def populate_files_table(self): + files_table = cast("DataTable", self.query_one("#files")) + # TODO: restore selection + selected_row = files_table.cursor_row + files_table.clear() + + for p, (_source_file_mutation_data, stat_for_row) in sorted( + self.source_file_mutation_data_and_stat_by_path.items() + ): + row = [p] + [ + Text(str(getattr(stat_for_row, k.replace(" ", "_"))), justify="right") + for k, _ in self.columns[1:] + ] + files_table.add_row(*row, key=str(p)) + + files_table.move_cursor(row=selected_row) + + def on_data_table_row_highlighted(self, event): # noqa: PLR0912, PLR0915 + if not event.row_key or not event.row_key.value: + return + if event.data_table.id == "files": + mutants_table = cast("DataTable", self.query_one("#mutants")) + mutants_table.clear() + source_file_mutation_data, _stat = self.source_file_mutation_data_and_stat_by_path[ + event.row_key.value + ] + for k, v in source_file_mutation_data.exit_code_by_key.items(): + status = status_by_exit_code[v] + if status != "killed" or show_killed: + mutants_table.add_row(k, emoji_by_status[status], key=k) + else: + if event.data_table.id != "mutants": + msg = f"Unexpected data table {event.data_table.id}" + raise ValueError(msg) + description_view = cast("Static", self.query_one("#description")) + mutant_name = event.row_key.value + self.loading_id = mutant_name + path = self.path_by_name.get(mutant_name) + if path is None: + msg = f"Path for mutant {mutant_name} is unknown" + raise ValueError(msg) + source_file_mutation_data, _stat = self.source_file_mutation_data_and_stat_by_path[path] + + exit_code = source_file_mutation_data.exit_code_by_key[mutant_name] + status = status_by_exit_code[exit_code] + estimated_duration = source_file_mutation_data.estimated_time_of_tests_by_mutant.get( + mutant_name, "?" + ) + duration = source_file_mutation_data.durations_by_key.get(mutant_name, "?") + + view_tests_description = "(press t to view tests executed for this mutant)" + + match status: + case "killed": + description = f"Killed ({exit_code=}): Mutant caused a test to fail 🎉" + case "survived": + description = ( + f"Survived ({exit_code=}): No test detected this mutant. {view_tests_description}" + ) + case "skipped": + description = f"Skipped ({exit_code=})" + case "check was interrupted by user": + description = f"User interrupted ({exit_code=})" + case "timeout": + description = ( + f"Timeout ({exit_code=}): Timed out because tests did not finish " + f"within {duration:.3f} seconds. Tests without mutation took " + f"{estimated_duration:.3f} seconds. {view_tests_description}" + ) + case "no tests": + description = ( + f"Untested ({exit_code=}): Skipped because selected tests do not " + "execute this code." + ) + case "segfault": + description = f"Segfault ({exit_code=}): Running pytest with this mutant segfaulted." + case "suspicious": + description = ( + f"Unknown ({exit_code=}): Running pytest with this mutant resulted " + "in an unknown exit code." + ) + case "not checked": + description = "Not checked in the last nootnoot run." + case _: + description = f"Unknown status ({exit_code=}, {status=})" + description_view.update(f"\n {description}\n") + + diff_view = cast("Static", self.query_one("#diff_view")) + diff_view.update("") + + def load_thread(): + ensure_config_loaded(self._state) + try: + d = get_diff_for_mutant(self._state, event.row_key.value, path=path) + if event.row_key.value == self.loading_id: + diff_view.update(Syntax(d, "diff")) + except Exception as e: # noqa: BLE001 + diff_view.update(f"<{type(e)} {e}>") + + t = Thread(target=load_thread) + t.start() + + def retest(self, pattern): + self._run_subprocess_command("run", [pattern]) + + def view_tests(self, mutant_name: str) -> None: + self._run_subprocess_command("tests-for-mutant", [mutant_name]) + + def _run_subprocess_command(self, command: str, args: list[str]) -> None: + with self.suspend(): + browse_index = sys.argv.index("browse") + initial_args = sys.argv[:browse_index] + subprocess_args = [sys.executable, *initial_args, command, *args] + print(">", *subprocess_args, file=sys.stderr) + subprocess.run(subprocess_args, check=False) # noqa: S603 + input("press enter to return to browser") + + self.read_data() + self.populate_files_table() + + def get_mutant_name_from_selection(self): + mutants_table = cast("DataTable", self.query_one("#mutants")) + if mutants_table.cursor_row is None: + return None + + row = mutants_table.get_row_at(mutants_table.cursor_row) + return str(row[0]) + + def action_retest_mutant(self): + mutant_name = self.get_mutant_name_from_selection() + if mutant_name is None: + return + self.retest(mutant_name) + + def action_retest_function(self): + mutant_name = self.get_mutant_name_from_selection() + if mutant_name is None: + return + self.retest(mutant_name.rpartition("__nootnoot_")[0] + "__nootnoot_*") + + def action_retest_module(self): + mutant_name = self.get_mutant_name_from_selection() + if mutant_name is None: + return + self.retest(mutant_name.rpartition(".")[0] + ".*") + + def action_apply_mutant(self): + ensure_config_loaded(self._state) + mutant_name = self.get_mutant_name_from_selection() + if mutant_name is None: + return + apply_mutant(self._state, mutant_name) + + def action_view_tests(self): + mutant_name = self.get_mutant_name_from_selection() + if mutant_name is None: + return + self.view_tests(mutant_name) + + ResultBrowser().run() diff --git a/src/nootnoot/cli/print_time_estimates.py b/src/nootnoot/cli/print_time_estimates.py new file mode 100644 index 00000000..a6426521 --- /dev/null +++ b/src/nootnoot/cli/print_time_estimates.py @@ -0,0 +1,38 @@ +import click + +from nootnoot.app.config import ensure_config_loaded +from nootnoot.app.mutation import collect_source_file_mutation_data, estimated_worst_case_time +from nootnoot.app.runners import PytestRunner +from nootnoot.app.state import NootNootState + +from .shared import collect_or_load_stats + + +@click.command() +@click.argument("mutant_names", required=False, nargs=-1) +@click.pass_obj +def print_time_estimates(state: NootNootState, mutant_names: tuple[str, ...] | list[str]) -> None: + if not isinstance(mutant_names, (tuple, list)): + msg = f"mutant_names must be tuple or list, got {type(mutant_names)}" + raise TypeError(msg) + ensure_config_loaded(state) + + runner = PytestRunner(state) + runner.prepare_main_test_run() + + collect_or_load_stats(runner, state) + + mutants, _source_file_mutation_data_by_path = collect_source_file_mutation_data( + mutant_names=mutant_names, + state=state, + ) + + times_and_keys = [ + (estimated_worst_case_time(state, mutant_name), mutant_name) for m, mutant_name, result in mutants + ] + + for time, key in sorted(times_and_keys): + if not time: + print("", key) + else: + print(f"{int(time * 1000)}ms", key) diff --git a/src/mutmut/result_browser_layout.tcss b/src/nootnoot/cli/result_browser_layout.tcss similarity index 100% rename from src/mutmut/result_browser_layout.tcss rename to src/nootnoot/cli/result_browser_layout.tcss diff --git a/src/nootnoot/cli/results.py b/src/nootnoot/cli/results.py new file mode 100644 index 00000000..87a1c5cd --- /dev/null +++ b/src/nootnoot/cli/results.py @@ -0,0 +1,25 @@ +import click + +from nootnoot.app.config import ensure_config_loaded +from nootnoot.app.meta import SourceFileMutationData +from nootnoot.app.mutation import status_by_exit_code, walk_source_files +from nootnoot.app.state import NootNootState + + +@click.command() +@click.option("--all", "show_all", default=False) +@click.pass_obj +def results(state: NootNootState, *, show_all: bool) -> None: + ensure_config_loaded(state) + config = state.config + debug = config.debug if config else False + for path in walk_source_files(state): + if not str(path).endswith(".py"): + continue + m = SourceFileMutationData(path=path) + m.load(debug=debug) + for k, v in m.exit_code_by_key.items(): + status = status_by_exit_code[v] + if status == "killed" and not show_all: + continue + print(f" {k}: {status}") diff --git a/src/nootnoot/cli/root.py b/src/nootnoot/cli/root.py new file mode 100644 index 00000000..da16d2a0 --- /dev/null +++ b/src/nootnoot/cli/root.py @@ -0,0 +1,27 @@ +import click + +from nootnoot.app.state import NootNootState + +from .apply import apply +from .browse import browse +from .print_time_estimates import print_time_estimates +from .results import results +from .run import run +from .show import show +from .tests_for_mutant import tests_for_mutant + + +@click.group() +@click.version_option() +@click.pass_context +def cli(ctx: click.Context) -> None: + ctx.obj = NootNootState() + + +cli.add_command(print_time_estimates) +cli.add_command(tests_for_mutant) +cli.add_command(run) +cli.add_command(results) +cli.add_command(show) +cli.add_command(apply) +cli.add_command(browse) diff --git a/src/nootnoot/cli/run.py b/src/nootnoot/cli/run.py new file mode 100644 index 00000000..64165337 --- /dev/null +++ b/src/nootnoot/cli/run.py @@ -0,0 +1,405 @@ +import gc +import os +import resource +import signal +import sys +from contextlib import suppress +from math import ceil +from multiprocessing import set_start_method +from pathlib import Path +from threading import Thread +from time import process_time, sleep + +import click +from setproctitle import setproctitle + +from nootnoot.app.config import ensure_config_loaded, get_config +from nootnoot.app.events import EventSink, ListEventSink, emit_event +from nootnoot.app.meta import START_TIMES_BY_PID_LOCK, SourceFileMutationData +from nootnoot.app.mutant_env import mutant_under_test, set_mutant_under_test +from nootnoot.app.mutation import ( + calculate_summary_stats, + collect_source_file_mutation_data, + copy_also_copy_files, + copy_src_dir, + create_mutants, + emoji_by_status, + estimated_worst_case_time, + mangled_name_from_mutant_name, + setup_source_paths, + status_by_exit_code, + store_lines_covered_by_tests, + tests_for_mutant_names, + utcnow, +) +from nootnoot.app.reporting import RunReport, render_json_report +from nootnoot.app.runners import PytestRunner +from nootnoot.app.state import NootNootState, set_state + +from .shared import CatchOutput, collect_or_load_stats, print_stats, run_forced_fail_test + + +def stop_all_children(mutants): + for m, _, _ in mutants: + m.stop_children() + + +# used to copy the configuration when spawning subprocesses +with suppress(RuntimeError): + set_start_method("fork") + + +def timeout_checker(mutants): + def inner_timeout_checker(): + while True: + sleep(1) + + now = utcnow() + for m, mutant_name, _result in mutants: + # copy dict inside lock, so it is not modified by another process while we iterate it + with START_TIMES_BY_PID_LOCK: + start_times_by_pid = dict(m.start_time_by_pid) + for pid, start_time in start_times_by_pid.items(): + run_time = now - start_time + if run_time.total_seconds() > (m.estimated_time_of_tests_by_mutant[mutant_name] + 1) * 15: + with suppress(ProcessLookupError): + os.kill(pid, signal.SIGXCPU) + + return inner_timeout_checker + + +def _diagnostic(message: str) -> None: + print(message, file=sys.stderr) + + +@click.command() +@click.option("--max-children", type=int) +@click.option( + "--format", + "output_format", + type=click.Choice(["human", "json"]), + default="human", + show_default=True, +) +@click.argument("mutant_names", required=False, nargs=-1) +@click.pass_obj +def run( + state: NootNootState, + mutant_names: tuple[str, ...] | list[str], + *, + max_children: int | None, + output_format: str, +) -> None: + if not isinstance(mutant_names, (tuple, list)): + msg = f"mutant_names must be tuple or list, got {type(mutant_names)}" + raise TypeError(msg) + event_sink = ListEventSink() if output_format == "json" else None + report = _run( + state, + mutant_names, + max_children, + output_format=output_format, + event_sink=event_sink, + ) + if output_format == "json" and report is not None: + click.echo(render_json_report(report)) + + +# separate function, so we can call it directly from the tests +def _run( # noqa: PLR0912, PLR0914, PLR0915 + state: NootNootState, + mutant_names: tuple[str, ...] | list[str], + max_children: int | None, + *, + output_format: str = "human", + event_sink: EventSink | None = None, +) -> RunReport | None: + # TODO: run no-ops once in a while to detect if we get false negatives + # TODO: we should be able to get information on which tests killed mutants, + # which means we can get a list of tests and how many mutants each test kills. + # Those that kill zero mutants are redundant! + set_state(state) + if not hasattr(os, "fork"): + print("nootnoot run requires os.fork, which is unavailable on this platform.", file=sys.stderr) + sys.exit(2) + ensure_config_loaded(state) + config = get_config(state) + + if max_children is None: + max_children = os.cpu_count() or 4 + + emit_event( + event_sink, + "session_started", + { + "max_children": max_children, + "mutant_names": list(mutant_names), + }, + ) + + force_redirect = output_format == "json" + start = utcnow() + Path("mutants").mkdir(exist_ok=True, parents=True) + with ( + mutant_under_test("mutant_generation"), + CatchOutput( + state=state, + spinner_title="Generating mutants", + force_redirect=force_redirect, + ), + ): + copy_src_dir(state) + copy_also_copy_files(state) + setup_source_paths() + store_lines_covered_by_tests(state) + create_mutants(max_children, state) + + time = utcnow() - start + if output_format == "human": + _diagnostic(f" done in {round(time.total_seconds() * 1000)}ms") + emit_event( + event_sink, + "mutants_generated", + {"elapsed_ms": round(time.total_seconds() * 1000)}, + ) + + # TODO: config/option for runner + # runner = HammettRunner() + runner = PytestRunner(state) + runner.prepare_main_test_run() + + # TODO: run these steps only if we have mutants to test + + collect_or_load_stats(runner, state, force_redirect=force_redirect) + + mutants, source_file_mutation_data_by_path = collect_source_file_mutation_data( + mutant_names=mutant_names, + state=state, + ) + + with CatchOutput( + state=state, + spinner_title="Running clean tests", + force_redirect=force_redirect, + ) as output_catcher: + tests = tests_for_mutant_names(state, mutant_names) + with mutant_under_test(""): + clean_test_exit_code = runner.run_tests(mutant_name=None, tests=tests) + if clean_test_exit_code != 0: + output_catcher.dump_output() + print("Failed to run clean test", file=sys.stderr) + sys.exit(1) + if output_format == "human": + _diagnostic(" done") + + # this can't be the first thing, because it can fail deep inside pytest/django + # setup and then everything is destroyed + run_forced_fail_test(runner, state, force_redirect=force_redirect) + + runner.prepare_main_test_run() + + def read_one_child_exit_status(): + pid, wait_status = os.wait() + exit_code = os.waitstatus_to_exitcode(wait_status) + if config.debug: + print(" worker exit code", exit_code, file=sys.stderr) + source_data = source_file_mutation_data_by_pid[pid] + mutant_key = source_data.key_by_pid.get(pid) + source_data.register_result(pid=pid, exit_code=exit_code) + if mutant_key is not None: + emit_event( + event_sink, + "mutant_finished", + { + "name": mutant_key, + "path": str(source_data.path), + "exit_code": exit_code, + "status": status_by_exit_code[exit_code], + "duration_seconds": source_data.durations_by_key.get(mutant_key), + }, + ) + + source_file_mutation_data_by_pid: dict[ + int, SourceFileMutationData + ] = {} # many pids map to one MutationData + running_children = 0 + count_tried = 0 + + # Run estimated fast mutants first, calculated as the estimated time for a surviving mutant. + mutants = sorted(mutants, key=lambda x: estimated_worst_case_time(state, x[1])) + + gc.freeze() + + start = utcnow() + try: + if output_format == "human": + _diagnostic("Running mutation testing") + + # Calculate times of tests + for source_data, mutant_name, _ in mutants: + normalized_mutant_name = mutant_name.replace("__init__.", "") + tests = state.tests_by_mangled_function_name.get( + mangled_name_from_mutant_name(normalized_mutant_name), [] + ) + estimated_time_of_tests = sum(state.duration_by_test[test_name] for test_name in tests) + source_data.estimated_time_of_tests_by_mutant[normalized_mutant_name] = estimated_time_of_tests + + Thread(target=timeout_checker(mutants), daemon=True).start() + + # Now do mutation + for source_data, mutant_name, previous_result in mutants: + if output_format == "human": + print_stats(source_file_mutation_data_by_path) + + normalized_mutant_name = mutant_name.replace("__init__.", "") + + # Rerun mutant if it's explicitly mentioned, but otherwise let the result stand + if not mutant_names and previous_result is not None: + continue + + tests = state.tests_by_mangled_function_name.get( + mangled_name_from_mutant_name(normalized_mutant_name), [] + ) + + # print(tests) + if not tests: + source_data.exit_code_by_key[normalized_mutant_name] = 33 + source_data.save() + emit_event( + event_sink, + "mutant_finished", + { + "name": normalized_mutant_name, + "path": str(source_data.path), + "exit_code": 33, + "status": status_by_exit_code[33], + }, + ) + continue + + pid = os.fork() + if not pid: + # In the child + set_mutant_under_test(normalized_mutant_name) + setproctitle(f"nootnoot: {normalized_mutant_name}") + + # Run fast tests first + tests = sorted(tests, key=lambda test_name: state.duration_by_test[test_name]) + if not tests: + os._exit(33) + + estimated_time_of_tests = source_data.estimated_time_of_tests_by_mutant[ + normalized_mutant_name + ] + cpu_time_limit = ceil((estimated_time_of_tests + 1) * 30 + process_time()) + # signal SIGXCPU after . One second later signal + # SIGKILL if it is still running + resource.setrlimit(resource.RLIMIT_CPU, (cpu_time_limit, cpu_time_limit + 1)) + + with CatchOutput(state=state, force_redirect=force_redirect): + test_result = runner.run_tests(mutant_name=normalized_mutant_name, tests=tests) + + if test_result != 0: + # TODO: write failure information to stdout? + pass + os._exit(test_result) + else: + # in the parent + source_file_mutation_data_by_pid[pid] = source_data + source_data.register_pid(pid=pid, key=normalized_mutant_name) + emit_event( + event_sink, + "mutant_started", + { + "name": normalized_mutant_name, + "path": str(source_data.path), + "tests_count": len(tests), + }, + ) + running_children += 1 + + if running_children >= max_children: + read_one_child_exit_status() + count_tried += 1 + running_children -= 1 + + try: + while running_children: + read_one_child_exit_status() + count_tried += 1 + running_children -= 1 + except ChildProcessError: + pass + except KeyboardInterrupt: + _diagnostic("Stopping...") + stop_all_children(mutants) + emit_event( + event_sink, + "session_interrupted", + {}, + ) + + t = utcnow() - start + + if output_format == "human": + print_stats(source_file_mutation_data_by_path, force_output=True) + _diagnostic("") + _diagnostic(f"{count_tried / t.total_seconds():.2f} mutations/second") + + summary_stats = calculate_summary_stats(source_file_mutation_data_by_path) + emit_event( + event_sink, + "session_finished", + { + "summary": summary_stats.__dict__.copy(), + "duration_seconds": t.total_seconds(), + "mutations_per_second": count_tried / t.total_seconds() if t.total_seconds() else 0.0, + }, + ) + + if mutant_names and output_format == "human": + print() + print("Mutant results") + print("--------------") + exit_code_by_key = {} + # If the user gave a specific list of mutants, print result for these specifically + for source_data, mutant_name, _ in mutants: + normalized_mutant_name = mutant_name.replace("__init__.", "") + exit_code_by_key[normalized_mutant_name] = source_data.exit_code_by_key[normalized_mutant_name] + + for mutant_name, exit_code in sorted(exit_code_by_key.items()): + print(emoji_by_status.get(status_by_exit_code[exit_code], "?"), mutant_name) + + print() + + if output_format == "json" and isinstance(event_sink, ListEventSink): + summary = { + "not_checked": summary_stats.not_checked, + "killed": summary_stats.killed, + "survived": summary_stats.survived, + "total": summary_stats.total, + "no_tests": summary_stats.no_tests, + "skipped": summary_stats.skipped, + "suspicious": summary_stats.suspicious, + "timeout": summary_stats.timeout, + "check_was_interrupted_by_user": summary_stats.check_was_interrupted_by_user, + "segfault": summary_stats.segfault, + } + mutants_payload = [] + for path, data in sorted(source_file_mutation_data_by_path.items()): + for mutant_name in sorted(data.exit_code_by_key): + exit_code = data.exit_code_by_key[mutant_name] + mutants_payload.append({ + "name": mutant_name, + "path": path, + "exit_code": exit_code, + "status": status_by_exit_code[exit_code], + "duration_seconds": data.durations_by_key.get(mutant_name), + "estimated_duration_seconds": data.estimated_time_of_tests_by_mutant.get(mutant_name), + }) + return RunReport( + summary=summary, + mutants=mutants_payload, + events=event_sink.events, + ) + return None diff --git a/src/nootnoot/cli/shared.py b/src/nootnoot/cli/shared.py new file mode 100644 index 00000000..3c150d0b --- /dev/null +++ b/src/nootnoot/cli/shared.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +import os +import sys +from io import TextIOBase +from time import process_time +from typing import IO, TYPE_CHECKING + +from rich.console import Console + +from nootnoot.app.config import get_config +from nootnoot.app.mutant_env import mutant_under_test +from nootnoot.app.mutation import ( + NootNootProgrammaticFailException, + calculate_summary_stats, + collected_test_names, +) +from nootnoot.app.persistence import load_stats, save_stats +from nootnoot.app.runners import CollectTestsFailedException, TestRunner + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + + from rich.status import Status + + from nootnoot.app.meta import SourceFileMutationData + from nootnoot.app.state import NootNootState + +console = Console( + file=sys.__stderr__ or sys.stderr, +) # use rich (via textual) for deterministic spinner instead of reimplementing animation. + + +def print_status(message: str, *, force_output: bool = False) -> None: + console.print(message) + if force_output: + console.file.flush() + + +def print_stats( + source_file_mutation_data_by_path: dict[str, SourceFileMutationData], + *, + force_output: bool = False, +) -> None: + s = calculate_summary_stats(source_file_mutation_data_by_path) + summary = ( + f"{(s.total - s.not_checked)}/{s.total} 🎉 {s.killed} 🫥 {s.no_tests} " + f"⏰ {s.timeout} 🤔 {s.suspicious} 🙁 {s.survived} 🔇 {s.skipped}" + ) + print_status(summary, force_output=force_output) + + +def run_forced_fail_test( + runner: TestRunner, + state: NootNootState, + *, + force_redirect: bool = False, +) -> None: + with ( + mutant_under_test("fail"), + CatchOutput( + state=state, + spinner_title="Running forced fail test", + output_stream=sys.stderr, + force_redirect=force_redirect, + ) as catcher, + ): + try: + if runner.run_forced_fail() == 0: + catcher.dump_output() + print("FAILED: Unable to force test failures", file=sys.stderr) + raise SystemExit(1) + except NootNootProgrammaticFailException: + pass + print(" done", file=sys.stderr) + + +class CatchOutput: + def __init__( + self, + *, + state: NootNootState, + callback: Callable[[str], None] = lambda _s: None, + spinner_title: str | None = None, + output_stream: TextIOBase | IO[str] | None = None, + force_redirect: bool = False, + ): + self.strings = [] + self.spinner_title = spinner_title or "" + config = state.config + self._state = state + self._status: Status | None = None + self._is_debug = config is not None and config.debug + self._output_stream = output_stream or sys.stderr + self._force_redirect = force_redirect + + class StdOutRedirect(TextIOBase): + def __init__(self, catcher: CatchOutput): + self.catcher = catcher + + def write(self, s: str) -> int: + callback(s) + self.catcher.strings.append(s) + return len(s) + + self.redirect = StdOutRedirect(self) + + def stop(self): + if self._status is not None: + self._status.stop() + self._status = None + sys.stdout = sys.__stdout__ + sys.stderr = sys.__stderr__ + + def start(self): + if self.spinner_title and not self._is_debug: + self._status = console.status(self.spinner_title, spinner="dots") + self._status.start() + elif self.spinner_title: + console.print(self.spinner_title) + console.print() + sys.stdout = self.redirect + sys.stderr = self.redirect + if self._is_debug and not self._force_redirect: + self.stop() + + def dump_output(self): + self.stop() + print(file=self._output_stream) + for line in self.strings: + print(line, end="", file=self._output_stream) + + def __enter__(self): + """Start redirecting stdout/stderr and return the catcher.""" + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Restore the original stdout/stderr streams.""" + self.stop() + if self.spinner_title: + print(file=self._output_stream) + + +def run_stats_collection( + runner: TestRunner, + state: NootNootState, + tests: Iterable[str] | None = None, + *, + force_redirect: bool = False, +) -> None: + if tests is None: + tests = [] # Meaning all... + + config = get_config(state) + os.environ["PY_IGNORE_IMPORTMISMATCH"] = "1" + start_cpu_time = process_time() + + with ( + mutant_under_test("stats"), + CatchOutput( + state=state, + spinner_title="Running stats", + output_stream=sys.stderr, + force_redirect=force_redirect, + ) as output_catcher, + ): + collect_stats_exit_code = runner.run_stats(tests=tests) + if collect_stats_exit_code != 0: + output_catcher.dump_output() + print( + f"failed to collect stats. runner returned {collect_stats_exit_code}", + file=sys.stderr, + ) + sys.exit(1) + # ensure that at least one mutant has associated tests + num_associated_tests = sum(len(tests) for tests in state.tests_by_mangled_function_name.values()) + if num_associated_tests == 0: + output_catcher.dump_output() + print( + "Stopping early, because we could not find any test case for any mutant. " + "It seems that the selected tests do not cover any code that we mutated.", + file=sys.stderr, + ) + if not config.debug: + print( + "You can set debug=true to see the executed test names in the output above.", + file=sys.stderr, + ) + else: + print( + "In the last pytest run above, you can see which tests we executed.", + file=sys.stderr, + ) + print( + "You can use nootnoot browse to check which parts of the source code we mutated.", + file=sys.stderr, + ) + print( + "If some of the mutated code should be covered by the executed tests, " + "consider opening an issue (with a MRE if possible).", + file=sys.stderr, + ) + sys.exit(1) + + print(" done", file=sys.stderr) + if not tests: # again, meaning all + state.stats_time = process_time() - start_cpu_time + + if not collected_test_names(state): + print("failed to collect stats, no active tests found", file=sys.stderr) + sys.exit(1) + + save_stats(state) + + +def collect_or_load_stats( + runner: TestRunner, + state: NootNootState, + *, + force_redirect: bool = False, +) -> None: + did_load = load_stats(state) + + if not did_load: + # Run full stats + run_stats_collection(runner, state, force_redirect=force_redirect) + else: + # Run incremental stats + with ( + CatchOutput( + state=state, + spinner_title="Listing all tests", + output_stream=sys.stderr, + force_redirect=force_redirect, + ) as output_catcher, + mutant_under_test("list_all_tests"), + ): + try: + all_tests_result = runner.list_all_tests() + except CollectTestsFailedException: + output_catcher.dump_output() + print("Failed to collect list of tests", file=sys.stderr) + sys.exit(1) + + all_tests_result.clear_out_obsolete_test_names() + + new_tests = all_tests_result.new_tests() + + if new_tests: + print( + f"Found {len(new_tests)} new tests, rerunning stats collection", + file=sys.stderr, + ) + run_stats_collection(runner, state, tests=new_tests, force_redirect=force_redirect) diff --git a/src/nootnoot/cli/show.py b/src/nootnoot/cli/show.py new file mode 100644 index 00000000..c7b7d249 --- /dev/null +++ b/src/nootnoot/cli/show.py @@ -0,0 +1,13 @@ +import click + +from nootnoot.app.config import ensure_config_loaded +from nootnoot.app.mutation import get_diff_for_mutant +from nootnoot.app.state import NootNootState + + +@click.command() +@click.argument("mutant_name") +@click.pass_obj +def show(state: NootNootState, mutant_name: str) -> None: + ensure_config_loaded(state) + print(get_diff_for_mutant(state, mutant_name)) diff --git a/src/nootnoot/cli/tests_for_mutant.py b/src/nootnoot/cli/tests_for_mutant.py new file mode 100644 index 00000000..41fb3f91 --- /dev/null +++ b/src/nootnoot/cli/tests_for_mutant.py @@ -0,0 +1,23 @@ +import sys + +import click + +from nootnoot.app.mutation import tests_for_mutant_names +from nootnoot.app.persistence import load_stats +from nootnoot.app.state import NootNootState + + +@click.command() +@click.argument("mutant_name", required=True, nargs=1) +@click.pass_obj +def tests_for_mutant(state: NootNootState, mutant_name: str) -> None: + if not load_stats(state): + print( + "Failed to load stats. Please run nootnoot first to collect stats.", + file=sys.stderr, + ) + sys.exit(1) + + tests = tests_for_mutant_names(state, [mutant_name]) + for test in sorted(tests): + print(test) diff --git a/src/nootnoot/core/__init__.py b/src/nootnoot/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/mutmut/file_mutation.py b/src/nootnoot/core/file_mutation.py similarity index 69% rename from src/mutmut/file_mutation.py rename to src/nootnoot/core/file_mutation.py index 2fa50ba6..7cba7287 100644 --- a/src/mutmut/file_mutation.py +++ b/src/nootnoot/core/file_mutation.py @@ -1,39 +1,41 @@ -"""This module contains code for managing mutant creation for whole files.""" +"""Manage mutant creation for whole files.""" from collections import defaultdict -from collections.abc import Iterable, Sequence, Mapping +from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass -from pathlib import Path -from typing import Union -import warnings +from typing import cast + import libcst as cst -from libcst.metadata import PositionProvider, MetadataWrapper import libcst.matchers as m -from mutmut.trampoline_templates import build_trampoline, mangle_function_name, trampoline_impl -from mutmut.node_mutation import mutation_operators, OPERATORS_TYPE +from libcst.metadata import MetadataWrapper, PositionProvider + +from nootnoot.core.node_mutation import OPERATORS_TYPE, mutation_operators +from nootnoot.core.trampoline_templates import build_trampoline, mangle_function_name, trampoline_impl + +NEVER_MUTATE_FUNCTION_NAMES = {"__getattribute__", "__setattr__", "__new__"} +NEVER_MUTATE_FUNCTION_CALLS = {"len", "isinstance"} -NEVER_MUTATE_FUNCTION_NAMES = { "__getattribute__", "__setattr__", "__new__" } -NEVER_MUTATE_FUNCTION_CALLS = { "len", "isinstance" } @dataclass class Mutation: original_node: cst.CSTNode mutated_node: cst.CSTNode - contained_by_top_level_function: Union[cst.FunctionDef, None] + contained_by_top_level_function: cst.FunctionDef | None -def mutate_file_contents(filename: str, code: str, covered_lines: Union[set[int], None] = None) -> tuple[str, Sequence[str]]: +def mutate_file_contents( + _filename: str, code: str, covered_lines: set[int] | None = None +) -> tuple[str, Sequence[str]]: """Create mutations for `code` and merge them to a single mutated file with trampolines. - :return: A tuple of (mutated code, list of mutant function names)""" + :return: A tuple of (mutated code, list of mutant function names) + """ module, mutations = create_mutations(code, covered_lines) return combine_mutations_to_source(module, mutations) -def create_mutations( - code: str, - covered_lines: Union[set[int], None] = None -) -> tuple[cst.Module, list[Mutation]]: + +def create_mutations(code: str, covered_lines: set[int] | None = None) -> tuple[cst.Module, list[Mutation]]: """Parse the code and create mutations.""" ignored_lines = pragma_no_mutate_lines(code) @@ -45,6 +47,7 @@ def create_mutations( return module, visitor.mutations + class OuterFunctionProvider(cst.BatchableMetadataProvider): """Link all nodes to the top-level function or method that contains them. @@ -55,13 +58,14 @@ def foo(): def bar(): x = 1 ``` - + Then `self.get_metadata(OuterFunctionProvider, )` returns ``. """ + def __init__(self): super().__init__() - def visit_Module(self, node: cst.Module): + def visit_Module(self, node: cst.Module) -> bool: # noqa: N802 for child in node.body: if isinstance(child, cst.FunctionDef): # mark all nodes inside the function to belong to this function @@ -77,25 +81,30 @@ def visit_Module(self, node: cst.Module): class OuterFunctionVisitor(cst.CSTVisitor): """Mark all nodes as children of `top_level_node`.""" + def __init__(self, provider: "OuterFunctionProvider", top_level_node: cst.CSTNode) -> None: self.provider = provider self.top_level_node = top_level_node super().__init__() - def on_visit(self, node: cst.CSTNode): + def on_visit(self, node: cst.CSTNode) -> bool: self.provider.set_metadata(node, self.top_level_node) return True class MutationVisitor(cst.CSTVisitor): """Iterate through all nodes in the module and create mutations for them. + Ignore nodes at lines `ignore_lines` and several other cases (e.g. nodes within type annotations). - - The created mutations will be accessible at `self.mutations`.""" + + The created mutations will be accessible at `self.mutations`. + """ METADATA_DEPENDENCIES = (PositionProvider, OuterFunctionProvider) - def __init__(self, operators: OPERATORS_TYPE, ignore_lines: set[int], covered_lines: Union[set[int], None] = None): + def __init__( + self, operators: OPERATORS_TYPE, ignore_lines: set[int], covered_lines: set[int] | None = None + ): self.mutations: list[Mutation] = [] self._operators = operators self._ignored_lines = ignore_lines @@ -111,79 +120,92 @@ def on_visit(self, node): # continue to mutate children return True - def _create_mutations(self, node: cst.CSTNode): + def _create_mutations(self, node: cst.CSTNode) -> None: for t, operator in self._operators: if isinstance(node, t): for mutated_node in operator(node): mutation = Mutation( original_node=node, mutated_node=mutated_node, - contained_by_top_level_function=self.get_metadata(OuterFunctionProvider, node, None), # type: ignore + contained_by_top_level_function=cast( + "cst.FunctionDef | None", self.get_metadata(OuterFunctionProvider, node, None) + ), ) self.mutations.append(mutation) - def _should_mutate_node(self, node: cst.CSTNode): + def _should_mutate_node(self, node: cst.CSTNode) -> bool: # currently, the position metadata does not always exist # (see https://github.com/Instagram/LibCST/issues/1322) - position = self.get_metadata(PositionProvider,node, None) + position = self.get_metadata(PositionProvider, node, None) if position: # do not mutate nodes with a pragma: no mutate comment if position.start.line in self._ignored_lines: return False # do not mutate nodes that are not covered - if self._covered_lines is not None and not position.start.line in self._covered_lines: + if self._covered_lines is not None and position.start.line not in self._covered_lines: return False return True - def _skip_node_and_children(self, node: cst.CSTNode): - if (isinstance(node, cst.Call) and isinstance(node.func, cst.Name) and node.func.value in NEVER_MUTATE_FUNCTION_CALLS) \ - or (isinstance(node, cst.FunctionDef) and node.name.value in NEVER_MUTATE_FUNCTION_NAMES): + @staticmethod + def _skip_node_and_children(node: cst.CSTNode) -> bool: + if ( + isinstance(node, cst.Call) + and isinstance(node.func, cst.Name) + and node.func.value in NEVER_MUTATE_FUNCTION_CALLS + ) or (isinstance(node, cst.FunctionDef) and node.name.value in NEVER_MUTATE_FUNCTION_NAMES): return True # ignore everything inside of type annotations if isinstance(node, cst.Annotation): return True - # default args are executed at definition time + # default args are executed at definition time # We want to prevent e.g. def foo(x = abs(-1)) mutating to def foo(x = abs(None)), # which would raise an Exception as soon as the function is defined (can break the whole import) # Therefore we only allow simple default values, where mutations should not raise exceptions - if isinstance(node, cst.Param) and node.default and not isinstance(node.default, (cst.Name, cst.BaseNumber, cst.BaseString)): + if ( + isinstance(node, cst.Param) + and node.default + and not isinstance(node.default, (cst.Name, cst.BaseNumber, cst.BaseString)) + ): return True # ignore decorated functions, because - # 1) copying them for the trampoline setup can cause side effects (e.g. multiple @app.post("/foo") definitions) - # 2) decorators are executed when the function is defined, so we don't want to mutate their arguments and cause exceptions - # 3) @property decorators break the trampoline signature assignment (which expects it to be a function) - if isinstance(node, (cst.FunctionDef, cst.ClassDef)) and len(node.decorators): - return True - - return False + # 1) copying them for the trampoline setup can cause side effects + # (e.g. multiple @app.post("/foo") definitions) + # 2) decorators are executed when the function is defined, so we don't want + # to mutate their arguments and cause exceptions + # 3) @property decorators break the trampoline signature assignment + # (which expects it to be a function) + return bool(isinstance(node, (cst.FunctionDef, cst.ClassDef)) and len(node.decorators)) - -MODULE_STATEMENT = Union[cst.SimpleStatementLine, cst.BaseCompoundStatement] +MODULE_STATEMENT = cst.SimpleStatementLine | cst.BaseCompoundStatement # convert str trampoline implementations to CST nodes with some whitespace trampoline_impl_cst = list(cst.parse_module(trampoline_impl).body) -trampoline_impl_cst[-1] = trampoline_impl_cst[-1].with_changes(leading_lines = [cst.EmptyLine(), cst.EmptyLine()]) +trampoline_impl_cst[-1] = trampoline_impl_cst[-1].with_changes( + leading_lines=[cst.EmptyLine(), cst.EmptyLine()] +) -def combine_mutations_to_source(module: cst.Module, mutations: Sequence[Mutation]) -> tuple[str, Sequence[str]]: +def combine_mutations_to_source( + module: cst.Module, mutations: Sequence[Mutation] +) -> tuple[str, Sequence[str]]: """Create mutated functions and trampolines for all mutations and compile them to a single source code. - + :param module: The original parsed module :param mutations: Mutations that should be applied. - :return: Mutated code and list of mutation names""" - + :return: Mutated code and list of mutation names + """ # copy start of the module (in particular __future__ imports) result: list[MODULE_STATEMENT] = get_statements_until_func_or_class(module.body) mutation_names: list[str] = [] # statements we still need to potentially mutate and add to the result - remaining_statements = module.body[len(result):] + remaining_statements = module.body[len(result) :] # trampoline functions result.extend(trampoline_impl_cst) @@ -215,7 +237,9 @@ def combine_mutations_to_source(module: cst.Module, mutations: Sequence[Mutation if not isinstance(method, cst.FunctionDef) or not method_mutants: mutated_body.append(method) continue - nodes, mutant_names = function_trampoline_arrangement(method, method_mutants, class_name=cls.name.value) + nodes, mutant_names = function_trampoline_arrangement( + method, method_mutants, class_name=cls.name.value + ) mutated_body.extend(nodes) mutation_names.extend(mutant_names) @@ -224,31 +248,40 @@ def combine_mutations_to_source(module: cst.Module, mutations: Sequence[Mutation result.append(statement) mutated_module = module.with_changes(body=result) - return mutated_module.code, mutation_names + mutated_code = mutated_module.code + if not mutated_code.endswith("\n"): + mutated_code += "\n" + return mutated_code, mutation_names -def function_trampoline_arrangement(function: cst.FunctionDef, mutants: Iterable[Mutation], class_name: Union[str, None]) -> tuple[Sequence[MODULE_STATEMENT], Sequence[str]]: + +def function_trampoline_arrangement( + function: cst.FunctionDef, mutants: Iterable[Mutation], class_name: str | None +) -> tuple[Sequence[MODULE_STATEMENT], Sequence[str]]: """Create mutated functions and a trampoline that switches between original and mutated versions. - - :return: A tuple of (nodes, mutant names)""" + + :return: A tuple of (nodes, mutant names) + """ nodes: list[MODULE_STATEMENT] = [] mutant_names: list[str] = [] name = function.name.value - mangled_name = mangle_function_name(name=name, class_name=class_name) + '__mutmut' + mangled_name = mangle_function_name(name=name, class_name=class_name) + "__nootnoot" # copy of original function - nodes.append(function.with_changes(name=cst.Name(mangled_name + '_orig'))) + nodes.append(function.with_changes(name=cst.Name(mangled_name + "_orig"))) # mutated versions of the function for i, mutant in enumerate(mutants): - mutant_name = f'{mangled_name}_{i+1}' + mutant_name = f"{mangled_name}_{i + 1}" mutant_names.append(mutant_name) mutated_method = function.with_changes(name=cst.Name(mutant_name)) mutated_method = deep_replace(mutated_method, mutant.original_node, mutant.mutated_node) - nodes.append(mutated_method) # type: ignore + nodes.append(cast("MODULE_STATEMENT", mutated_method)) # trampoline that forwards the calls - trampoline = list(cst.parse_module(build_trampoline(orig_name=name, mutants=mutant_names, class_name=class_name)).body) + trampoline = list( + cst.parse_module(build_trampoline(orig_name=name, mutants=mutant_names, class_name=class_name)).body + ) trampoline[0] = trampoline[0].with_changes(leading_lines=[cst.EmptyLine()]) nodes.extend(trampoline) @@ -256,7 +289,7 @@ def function_trampoline_arrangement(function: cst.FunctionDef, mutants: Iterable def get_statements_until_func_or_class(statements: Sequence[MODULE_STATEMENT]) -> list[MODULE_STATEMENT]: - """Get all statements until we encounter the first function or class definition""" + """Get all statements until we encounter the first function or class definition.""" result = [] for stmt in statements: @@ -266,24 +299,28 @@ def get_statements_until_func_or_class(statements: Sequence[MODULE_STATEMENT]) - return result + def group_by_top_level_node(mutations: Sequence[Mutation]) -> Mapping[cst.CSTNode, Sequence[Mutation]]: grouped: dict[cst.CSTNode, list[Mutation]] = defaultdict(list) - for m in mutations: - if m.contained_by_top_level_function: - grouped[m.contained_by_top_level_function].append(m) + for mutation in mutations: + if mutation.contained_by_top_level_function: + grouped[mutation.contained_by_top_level_function].append(mutation) return grouped + def pragma_no_mutate_lines(source: str) -> set[int]: return { i + 1 - for i, line in enumerate(source.split('\n')) - if '# pragma:' in line and 'no mutate' in line.partition('# pragma:')[-1] + for i, line in enumerate(source.split("\n")) + if "# pragma:" in line and "no mutate" in line.partition("# pragma:")[-1] } + def deep_replace(tree: cst.CSTNode, old_node: cst.CSTNode, new_node: cst.CSTNode) -> cst.CSTNode: """Like the CSTNode.deep_replace method, except that we only replace up to one occurrence of old_node.""" - return tree.visit(ChildReplacementTransformer(old_node, new_node)) # type: ignore + return cast("cst.CSTNode", tree.visit(ChildReplacementTransformer(old_node, new_node))) + class ChildReplacementTransformer(cst.CSTTransformer): def __init__(self, old_node: cst.CSTNode, new_node: cst.CSTNode): diff --git a/src/mutmut/node_mutation.py b/src/nootnoot/core/node_mutation.py similarity index 63% rename from src/mutmut/node_mutation.py rename to src/nootnoot/core/node_mutation.py index 25941408..f0f9bcbb 100644 --- a/src/mutmut/node_mutation.py +++ b/src/nootnoot/core/node_mutation.py @@ -1,7 +1,10 @@ -"""This module contains the mutations for indidvidual nodes, e.g. replacing a != b with a == b.""" +"""Define node-level mutations, e.g. replacing a != b with a == b.""" + import re -from typing import Any, Union, cast +import sys from collections.abc import Callable, Iterable, Sequence +from typing import TYPE_CHECKING, Any + import libcst as cst import libcst.matchers as m @@ -15,29 +18,24 @@ # pattern to match (nearly) all chars in a string that are not part of an escape sequence NON_ESCAPE_SEQUENCE = re.compile(r"((? Iterable[cst.BaseNumber]: + +def operator_number(node: cst.BaseNumber) -> Iterable[cst.BaseNumber]: if isinstance(node, (cst.Integer, cst.Float)): yield node.with_changes(value=repr(node.evaluated_value + 1)) elif isinstance(node, cst.Imaginary): yield node.with_changes(value=repr(node.evaluated_value + 1j)) else: - print("Unexpected number type", node) + print("Unexpected number type", node, file=sys.stderr) -def operator_string( - node: cst.BaseString -) -> Iterable[cst.BaseString]: +def operator_string(node: cst.BaseString) -> Iterable[cst.BaseString]: if isinstance(node, cst.SimpleString): value = node.value old_value = value - prefix = value[ - : min([x for x in [value.find('"'), value.find("'")] if x != -1]) - ] + prefix = value[: min([x for x in [value.find('"'), value.find("'")] if x != -1])] value = value[len(prefix) :] - if value.startswith('"""') or value.startswith("'''"): + if value.startswith(('"""', "'''")): # We assume here that triple-quoted stuff are docs or other things # that mutation is meaningless for return @@ -58,19 +56,15 @@ def operator_string( yield node.with_changes(value=new_value) -def operator_lambda( - node: cst.Lambda -) -> Iterable[cst.Lambda]: +def operator_lambda(node: cst.Lambda) -> Iterable[cst.Lambda]: if m.matches(node, m.Lambda(body=m.Name("None"))): yield node.with_changes(body=cst.Integer("0")) else: yield node.with_changes(body=cst.Name("None")) -def operator_dict_arguments( - node: cst.Call -) -> Iterable[cst.Call]: - """mutate dict(a=b, c=d) to dict(aXX=b, c=d) and dict(a=b, cXX=d)""" +def operator_dict_arguments(node: cst.Call) -> Iterable[cst.Call]: + """Mutate dict(a=b, c=d) to dict(aXX=b, c=d) and dict(a=b, cXX=d).""" if not m.matches(node.func, m.Name(value="dict")): return @@ -82,18 +76,16 @@ def operator_dict_arguments( mutated_args = [ *node.args[:i], node.args[i].with_changes(keyword=mutated_keyword), - *node.args[i+1:], + *node.args[i + 1 :], ] yield node.with_changes(args=mutated_args) -def operator_arg_removal( - node: cst.Call -) -> Iterable[cst.Call]: - """try to drop each arg in a function call, e.g. foo(a, b) -> foo(b), foo(a)""" +def operator_arg_removal(node: cst.Call) -> Iterable[cst.Call]: + """Try to drop each arg in a function call, e.g. foo(a, b) -> foo(b), foo(a).""" for i, arg in enumerate(node.args): # replace with None - if arg.star == '' and not m.matches(arg.value, m.Name("None")): + if not arg.star and not m.matches(arg.value, m.Name("None")): mutated_arg = arg.with_changes(value=cst.Name("None")) yield node.with_changes(args=[*node.args[:i], mutated_arg, *node.args[i + 1 :]]) @@ -104,59 +96,53 @@ def operator_arg_removal( supported_symmetric_str_methods_swap = [ - ("lower", "upper"), - ("upper", "lower"), - ("lstrip", "rstrip"), - ("rstrip", "lstrip"), - ("find", "rfind"), - ("rfind", "find"), - ("ljust", "rjust"), - ("rjust", "ljust"), - ("index", "rindex"), - ("rindex", "index"), - ("removeprefix", "removesuffix"), - ("removesuffix", "removeprefix"), - ("partition", "rpartition"), - ("rpartition", "partition") + ("lower", "upper"), + ("upper", "lower"), + ("lstrip", "rstrip"), + ("rstrip", "lstrip"), + ("find", "rfind"), + ("rfind", "find"), + ("ljust", "rjust"), + ("rjust", "ljust"), + ("index", "rindex"), + ("rindex", "index"), + ("removeprefix", "removesuffix"), + ("removesuffix", "removeprefix"), + ("partition", "rpartition"), + ("rpartition", "partition"), ] -supported_unsymmetrical_str_methods_swap = [ - ("split", "rsplit"), - ("rsplit", "split") -] +supported_unsymmetrical_str_methods_swap = [("split", "rsplit"), ("rsplit", "split")] +SPLIT_ARG_LIMIT = 2 -def operator_symmetric_string_methods_swap( - node: cst.Call - ) -> Iterable[cst.Call]: - """try to swap string method to opposite e.g. a.lower() -> a.upper()""" - for old_call, new_call in supported_symmetric_str_methods_swap: - if m.matches(node.func, m.Attribute(value=m.DoNotCare(), attr=m.Name(value=old_call))): +def operator_symmetric_string_methods_swap(node: cst.Call) -> Iterable[cst.Call]: + """Try to swap string method to opposite e.g. a.lower() -> a.upper().""" + for old_call, new_call in supported_symmetric_str_methods_swap: + if m.matches(node.func, m.Attribute(value=m.DoNotCare(), attr=m.Name(value=old_call))): func_name = cst.ensure_type(node.func, cst.Attribute).attr yield node.with_deep_changes(func_name, value=new_call) -def operator_unsymmetrical_string_methods_swap( - node: cst.Call -) -> Iterable[cst.Call]: + +def operator_unsymmetrical_string_methods_swap(node: cst.Call) -> Iterable[cst.Call]: """Try to handle specific mutations of string, which useful only in specific args combination.""" for old_call, new_call in supported_unsymmetrical_str_methods_swap: - if m.matches(node.func, m.Attribute(attr=m.Name(value=old_call))): - if old_call in {"split", "rsplit"}: - # The logic of this "if" operator described here: - # https://github.com/boxed/mutmut/pull/394#issuecomment-2977890188 - key_args: set[str] = {a.keyword.value for a in node.args if a.keyword} # sep or maxsplit or nothing - if len(node.args) == 2 or "maxsplit" in key_args: - func_name = cst.ensure_type(node.func, cst.Attribute).attr - yield node.with_deep_changes(func_name, value=new_call) - - - -def operator_remove_unary_ops( - node: cst.UnaryOperation -) -> Iterable[cst.BaseExpression]: + if m.matches(node.func, m.Attribute(attr=m.Name(value=old_call))) and old_call in {"split", "rsplit"}: + # The logic of this "if" operator described here: + # https://github.com/boxed/nootnoot/pull/394#issuecomment-2977890188 + key_args: set[str] = { + a.keyword.value for a in node.args if a.keyword + } # sep or maxsplit or nothing + if len(node.args) == SPLIT_ARG_LIMIT or "maxsplit" in key_args: + func_name = cst.ensure_type(node.func, cst.Attribute).attr + yield node.with_deep_changes(func_name, value=new_call) + + +def operator_remove_unary_ops(node: cst.UnaryOperation) -> Iterable[cst.BaseExpression]: if isinstance(node.operator, (cst.Not, cst.BitInvert)): yield node.expression + _keyword_mapping: dict[type[cst.CSTNode], type[cst.CSTNode]] = { cst.Is: cst.IsNot, cst.IsNot: cst.Is, @@ -166,9 +152,8 @@ def operator_remove_unary_ops( cst.Continue: cst.Break, } -def operator_keywords( - node: cst.CSTNode -) -> Iterable[cst.CSTNode]: + +def operator_keywords(node: cst.CSTNode) -> Iterable[cst.CSTNode]: yield from _simple_mutation_mapping(node, _keyword_mapping) @@ -182,6 +167,7 @@ def operator_name(node: cst.Name) -> Iterable[cst.CSTNode]: if node.value in name_mappings: yield node.with_changes(value=name_mappings[node.value]) + _operator_mapping: dict[type[cst.CSTNode], type[cst.CSTNode]] = { cst.Plus: cst.Minus, cst.Add: cst.Subtract, @@ -219,42 +205,67 @@ def operator_name(node: cst.Name) -> Iterable[cst.CSTNode]: cst.Or: cst.And, } -def operator_swap_op( - node: cst.CSTNode -) -> Iterable[cst.CSTNode]: - if m.matches(node, m.BinaryOperation() | m.UnaryOperation() | m.BooleanOperation() | m.ComparisonTarget() | m.AugAssign()): - typed_node = cast(Union[cst.BinaryOperation, cst.UnaryOperation, cst.BooleanOperation, cst.ComparisonTarget, cst.AugAssign], node) +OperatorNode = ( + cst.BinaryOperation | cst.UnaryOperation | cst.BooleanOperation | cst.ComparisonTarget | cst.AugAssign +) + +if TYPE_CHECKING: + + def _as_operator_node(node: cst.CSTNode) -> OperatorNode: + assert isinstance( + node, + ( + cst.BinaryOperation, + cst.UnaryOperation, + cst.BooleanOperation, + cst.ComparisonTarget, + cst.AugAssign, + ), + ) + return node + +else: + + def _as_operator_node(node: cst.CSTNode) -> OperatorNode: + return node + + +def operator_swap_op(node: cst.CSTNode) -> Iterable[cst.CSTNode]: + if m.matches( + node, + m.BinaryOperation() + | m.UnaryOperation() + | m.BooleanOperation() + | m.ComparisonTarget() + | m.AugAssign(), + ): + typed_node = _as_operator_node(node) operator = typed_node.operator for new_operator in _simple_mutation_mapping(operator, _operator_mapping): yield node.with_changes(operator=new_operator) -def operator_augmented_assignment( - node: cst.AugAssign -) -> Iterable[cst.Assign]: - """mutate all augmented assignments (+=, *=, |=, etc.) to normal = assignments""" +def operator_augmented_assignment(node: cst.AugAssign) -> Iterable[cst.Assign]: + """Mutate all augmented assignments (+=, *=, |=, etc.) to normal = assignments.""" yield cst.Assign([cst.AssignTarget(node.target)], node.value, node.semicolon) -def operator_assignment( - node: Union[cst.Assign, cst.AnnAssign] -) -> Iterable[cst.CSTNode]: - """mutate `a = b` to `a = None` and `a = None` to `a = ""`""" +def operator_assignment(node: cst.Assign | cst.AnnAssign) -> Iterable[cst.CSTNode]: + """Mutate `a = b` to `a = None` and `a = None` to `a = ""`.""" if not node.value: # do not mutate `a: sometype` to an assignment `a: sometype = ""` return - if m.matches(node.value, m.Name("None")): - mutated_value = cst.SimpleString('""') - else: - mutated_value = cst.Name("None") + mutated_value = cst.SimpleString('""') if m.matches(node.value, m.Name("None")) else cst.Name("None") yield node.with_changes(value=mutated_value) + def operator_match(node: cst.Match) -> Iterable[cst.CSTNode]: """Drop the case statements in a match.""" if len(node.cases) > 1: for i in range(len(node.cases)): - yield node.with_changes(cases=[*node.cases[:i], *node.cases[i+1:]]) + yield node.with_changes(cases=[*node.cases[:i], *node.cases[i + 1 :]]) + # Operators that should be called on specific node types mutation_operators: OPERATORS_TYPE = [ @@ -279,7 +290,7 @@ def operator_match(node: cst.Match) -> Iterable[cst.CSTNode]: def _simple_mutation_mapping( node: cst.CSTNode, mapping: dict[type[cst.CSTNode], type[cst.CSTNode]] ) -> Iterable[cst.CSTNode]: - """Yield mutations from the node class mapping""" + """Yield mutations from the node class mapping.""" mutated_node_type = mapping.get(type(node)) if mutated_node_type: yield mutated_node_type() diff --git a/src/nootnoot/core/trampoline_runtime.py b/src/nootnoot/core/trampoline_runtime.py new file mode 100644 index 00000000..365b4606 --- /dev/null +++ b/src/nootnoot/core/trampoline_runtime.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import inspect +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + + +class _TrampolineHooks: + def __init__(self) -> None: + self.get_max_stack_depth: Callable[[], int] | None = None + self.add_stat: Callable[[str], None] | None = None + + +_trampoline_hooks = _TrampolineHooks() + + +class NootNootProgrammaticFailException(Exception): + pass + + +def register_trampoline_hooks( + *, + get_max_stack_depth: Callable[[], int] | None, + add_stat: Callable[[str], None] | None, +) -> None: + _trampoline_hooks.get_max_stack_depth = get_max_stack_depth + _trampoline_hooks.add_stat = add_stat + + +def record_trampoline_hit(name: str) -> None: + if name.startswith("src."): + msg = "Failed trampoline hit. Module name starts with `src.`, which is invalid" + raise ValueError(msg) + get_max_stack_depth = _trampoline_hooks.get_max_stack_depth + add_stat = _trampoline_hooks.add_stat + if get_max_stack_depth is None or add_stat is None: + return + max_stack_depth = int(get_max_stack_depth()) + if max_stack_depth != -1: + f = inspect.currentframe() + c = max_stack_depth + while c and f: + filename = f.f_code.co_filename + if "pytest" in filename or "hammett" in filename or "unittest" in filename: + break + f = f.f_back + c -= 1 + + if not c: + return + + add_stat(name) diff --git a/src/nootnoot/core/trampoline_templates.py b/src/nootnoot/core/trampoline_templates.py new file mode 100644 index 00000000..24202a1c --- /dev/null +++ b/src/nootnoot/core/trampoline_templates.py @@ -0,0 +1,90 @@ +CLASS_NAME_SEPARATOR = "ǁ" + + +def build_trampoline(*, orig_name, mutants, class_name): + mangled_name = mangle_function_name(name=orig_name, class_name=class_name) + + mutants_dict = ( + f"{mangled_name}__nootnoot_mutants : ClassVar[MutantDict] = {{\n" + + ",\n ".join(f"{m!r}: {m}" for m in mutants) + + "\n}" + ) + access_prefix = "" + access_suffix = "" + self_arg = "" + if class_name is not None: + access_prefix = 'object.__getattribute__(self, "' + access_suffix = '")' + self_arg = ", self" + + trampoline_name = "_nootnoot_trampoline" + trampoline_call = ( + f"{trampoline_name}(" + f"{access_prefix}{mangled_name}__nootnoot_orig{access_suffix}, " + f"{access_prefix}{mangled_name}__nootnoot_mutants{access_suffix}, " + f"args, kwargs{self_arg})" + ) + + return f""" +{mutants_dict} + +def {orig_name}({"self, " if class_name is not None else ""}*args, **kwargs): + result = {trampoline_call} + return result + +{orig_name}.__signature__ = _nootnoot_signature({mangled_name}__nootnoot_orig) +{mangled_name}__nootnoot_orig.__name__ = '{mangled_name}' +""" + + +def mangle_function_name(*, name, class_name): + if CLASS_NAME_SEPARATOR in name: + msg = f"Function name {name!r} cannot contain the class-name separator." + raise ValueError(msg) + if class_name: + if CLASS_NAME_SEPARATOR in class_name: + msg = f"Class name {class_name!r} cannot contain the class-name separator." + raise ValueError(msg) + prefix = f"x{CLASS_NAME_SEPARATOR}{class_name}{CLASS_NAME_SEPARATOR}" + else: + prefix = "x_" + return f"{prefix}{name}" + + +# noinspection PyUnresolvedReferences +# language=python +trampoline_impl = """ +from inspect import signature as _nootnoot_signature +from typing import Annotated +from typing import Callable +from typing import ClassVar + + +MutantDict = Annotated[dict[str, Callable], "Mutant"] + + +def _nootnoot_trampoline(orig, mutants, call_args, call_kwargs, self_arg = None): + \"""Forward call to original or mutated function, depending on the environment\""" + import os + mutant_under_test = os.environ['MUTANT_UNDER_TEST'] + if mutant_under_test == 'fail': + from nootnoot.core.trampoline_runtime import NootNootProgrammaticFailException + raise NootNootProgrammaticFailException('Failed programmatically') + elif mutant_under_test == 'stats': + from nootnoot.core.trampoline_runtime import record_trampoline_hit + record_trampoline_hit(orig.__module__ + '.' + orig.__name__) + result = orig(*call_args, **call_kwargs) + return result + prefix = orig.__module__ + '.' + orig.__name__ + '__nootnoot_' + if not mutant_under_test.startswith(prefix): + result = orig(*call_args, **call_kwargs) + return result + mutant_name = mutant_under_test.rpartition('.')[-1] + if self_arg is not None: + # call to a class method where self is not bound + result = mutants[mutant_name](self_arg, *call_args, **call_kwargs) + else: + result = mutants[mutant_name](*call_args, **call_kwargs) + return result + +""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..91add377 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import pytest + +from nootnoot.app.state import NootNootState, reset_state, set_state + + +@pytest.fixture +def nootnoot_state() -> NootNootState: + state = NootNootState() + token = set_state(state) + try: + yield state + finally: + reset_state(token) diff --git a/tests/data/module_mutation_expected.py.txt b/tests/data/module_mutation_expected.py.txt new file mode 100644 index 00000000..26a5f5e0 --- /dev/null +++ b/tests/data/module_mutation_expected.py.txt @@ -0,0 +1,106 @@ +from __future__ import division +import lib + +lib.foo() +from inspect import signature as _nootnoot_signature +from typing import Annotated +from typing import Callable +from typing import ClassVar + + +MutantDict = Annotated[dict[str, Callable], "Mutant"] + + +def _nootnoot_trampoline(orig, mutants, call_args, call_kwargs, self_arg = None): + """Forward call to original or mutated function, depending on the environment""" + import os + mutant_under_test = os.environ['MUTANT_UNDER_TEST'] + if mutant_under_test == 'fail': + from nootnoot.core.trampoline_runtime import NootNootProgrammaticFailException + raise NootNootProgrammaticFailException('Failed programmatically') + elif mutant_under_test == 'stats': + from nootnoot.core.trampoline_runtime import record_trampoline_hit + record_trampoline_hit(orig.__module__ + '.' + orig.__name__) + result = orig(*call_args, **call_kwargs) + return result + prefix = orig.__module__ + '.' + orig.__name__ + '__nootnoot_' + if not mutant_under_test.startswith(prefix): + result = orig(*call_args, **call_kwargs) + return result + mutant_name = mutant_under_test.rpartition('.')[-1] + if self_arg is not None: + # call to a class method where self is not bound + result = mutants[mutant_name](self_arg, *call_args, **call_kwargs) + else: + result = mutants[mutant_name](*call_args, **call_kwargs) + return result + +def x_foo__nootnoot_orig(a, b): + return a > b + +def x_foo__nootnoot_1(a, b): + return a >= b + +x_foo__nootnoot_mutants : ClassVar[MutantDict] = { +'x_foo__nootnoot_1': x_foo__nootnoot_1 +} + +def foo(*args, **kwargs): + result = _nootnoot_trampoline(x_foo__nootnoot_orig, x_foo__nootnoot_mutants, args, kwargs) + return result + +foo.__signature__ = _nootnoot_signature(x_foo__nootnoot_orig) +x_foo__nootnoot_orig.__name__ = 'x_foo' + +def x_bar__nootnoot_orig(): + yield 1 + +def x_bar__nootnoot_1(): + yield 2 + +x_bar__nootnoot_mutants : ClassVar[MutantDict] = { +'x_bar__nootnoot_1': x_bar__nootnoot_1 +} + +def bar(*args, **kwargs): + result = _nootnoot_trampoline(x_bar__nootnoot_orig, x_bar__nootnoot_mutants, args, kwargs) + return result + +bar.__signature__ = _nootnoot_signature(x_bar__nootnoot_orig) +x_bar__nootnoot_orig.__name__ = 'x_bar' + +class Adder: + def xǁAdderǁ__init____nootnoot_orig(self, amount): + self.amount = amount + def xǁAdderǁ__init____nootnoot_1(self, amount): + self.amount = None + + xǁAdderǁ__init____nootnoot_mutants : ClassVar[MutantDict] = { + 'xǁAdderǁ__init____nootnoot_1': xǁAdderǁ__init____nootnoot_1 + } + + def __init__(self, *args, **kwargs): + result = _nootnoot_trampoline(object.__getattribute__(self, "xǁAdderǁ__init____nootnoot_orig"), object.__getattribute__(self, "xǁAdderǁ__init____nootnoot_mutants"), args, kwargs, self) + return result + + __init__.__signature__ = _nootnoot_signature(xǁAdderǁ__init____nootnoot_orig) + xǁAdderǁ__init____nootnoot_orig.__name__ = 'xǁAdderǁ__init__' + + def xǁAdderǁadd__nootnoot_orig(self, value): + return self.amount + value + + def xǁAdderǁadd__nootnoot_1(self, value): + return self.amount - value + + xǁAdderǁadd__nootnoot_mutants : ClassVar[MutantDict] = { + 'xǁAdderǁadd__nootnoot_1': xǁAdderǁadd__nootnoot_1 + } + + def add(self, *args, **kwargs): + result = _nootnoot_trampoline(object.__getattribute__(self, "xǁAdderǁadd__nootnoot_orig"), object.__getattribute__(self, "xǁAdderǁadd__nootnoot_mutants"), args, kwargs, self) + return result + + add.__signature__ = _nootnoot_signature(xǁAdderǁadd__nootnoot_orig) + xǁAdderǁadd__nootnoot_orig.__name__ = 'xǁAdderǁadd' + +print(Adder(1).add(2)) diff --git a/tests/data/test_generation/valid_syntax_1.py b/tests/data/test_generation/valid_syntax_1.py index 068706ac..3fa82f10 100644 --- a/tests/data/test_generation/valid_syntax_1.py +++ b/tests/data/test_generation/valid_syntax_1.py @@ -1,2 +1,2 @@ def foo(): - return 1 + 2 \ No newline at end of file + return 1 + 2 diff --git a/tests/data/test_generation/valid_syntax_2.py b/tests/data/test_generation/valid_syntax_2.py index 137f4b7e..6d378b7e 100644 --- a/tests/data/test_generation/valid_syntax_2.py +++ b/tests/data/test_generation/valid_syntax_2.py @@ -1,2 +1,2 @@ def foo(): - return 2 + 3 \ No newline at end of file + return 2 + 3 diff --git a/tests/data/test_generation/valid_syntax_3.py b/tests/data/test_generation/valid_syntax_3.py index 82e60af8..d4007299 100644 --- a/tests/data/test_generation/valid_syntax_3.py +++ b/tests/data/test_generation/valid_syntax_3.py @@ -1,2 +1,2 @@ def foo(): - return 3 + 4 \ No newline at end of file + return 3 + 4 diff --git a/tests/data/test_generation/valid_syntax_4.py b/tests/data/test_generation/valid_syntax_4.py index d1dba07d..716532c8 100644 --- a/tests/data/test_generation/valid_syntax_4.py +++ b/tests/data/test_generation/valid_syntax_4.py @@ -1,2 +1,2 @@ def foo(): - return 4 + 5 \ No newline at end of file + return 4 + 5 diff --git a/tests/data/test_generation/valid_syntax_5.py b/tests/data/test_generation/valid_syntax_5.py index dac3e1ea..524b6222 100644 --- a/tests/data/test_generation/valid_syntax_5.py +++ b/tests/data/test_generation/valid_syntax_5.py @@ -1,2 +1,2 @@ def foo(): - return 5 + 6 \ No newline at end of file + return 5 + 6 diff --git a/tests/e2e/snapshots/config.json b/tests/e2e/snapshots/config.json index 2617dc47..917b193c 100644 --- a/tests/e2e/snapshots/config.json +++ b/tests/e2e/snapshots/config.json @@ -1,18 +1,18 @@ { "mutants/config_pkg/__init__.py.meta": { - "config_pkg.x_hello__mutmut_1": 1, - "config_pkg.x_hello__mutmut_2": 1, - "config_pkg.x_hello__mutmut_3": 1 + "config_pkg.x_hello__nootnoot_1": 1, + "config_pkg.x_hello__nootnoot_2": 1, + "config_pkg.x_hello__nootnoot_3": 1 }, "mutants/config_pkg/math.py.meta": { - "config_pkg.math.x_add__mutmut_1": 0, - "config_pkg.math.x_call_depth_two__mutmut_1": 1, - "config_pkg.math.x_call_depth_two__mutmut_2": 1, - "config_pkg.math.x_call_depth_three__mutmut_1": 1, - "config_pkg.math.x_call_depth_three__mutmut_2": 1, - "config_pkg.math.x_call_depth_four__mutmut_1": 33, - "config_pkg.math.x_call_depth_four__mutmut_2": 33, - "config_pkg.math.x_call_depth_five__mutmut_1": 33, - "config_pkg.math.x_func_with_no_tests__mutmut_1": 33 + "config_pkg.math.x_add__nootnoot_1": 0, + "config_pkg.math.x_call_depth_two__nootnoot_1": 1, + "config_pkg.math.x_call_depth_two__nootnoot_2": 1, + "config_pkg.math.x_call_depth_three__nootnoot_1": 1, + "config_pkg.math.x_call_depth_three__nootnoot_2": 1, + "config_pkg.math.x_call_depth_four__nootnoot_1": 33, + "config_pkg.math.x_call_depth_four__nootnoot_2": 33, + "config_pkg.math.x_call_depth_five__nootnoot_1": 33, + "config_pkg.math.x_func_with_no_tests__nootnoot_1": 33 } } \ No newline at end of file diff --git a/tests/e2e/snapshots/mutate_only_covered_lines.json b/tests/e2e/snapshots/mutate_only_covered_lines.json index 38cbeb3a..9aeb5f44 100644 --- a/tests/e2e/snapshots/mutate_only_covered_lines.json +++ b/tests/e2e/snapshots/mutate_only_covered_lines.json @@ -1,39 +1,39 @@ { "mutants/src/mutate_only_covered_lines/__init__.py.meta": { - "mutate_only_covered_lines.x_hello_mutate_only_covered_lines__mutmut_1": 1, - "mutate_only_covered_lines.x_hello_mutate_only_covered_lines__mutmut_2": 1, - "mutate_only_covered_lines.x_hello_mutate_only_covered_lines__mutmut_3": 1, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_1": 1, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_2": 1, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_3": 1, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_4": 1, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_5": 0, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_6": 0, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_7": 0, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_8": 0, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_9": 0, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_10": 0, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_11": 0, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_12": 0, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_13": 0, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_14": 0, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_15": 0, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_16": 0, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_17": 0, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_18": 0, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_19": 0, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_20": 0, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_21": 0, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_22": 0, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_23": 0, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_24": 1, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_25": 1, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_26": 1, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_27": 1, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_28": 1, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_29": 1, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_30": 1, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_31": 1, - "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_32": 1 + "mutate_only_covered_lines.x_hello_mutate_only_covered_lines__nootnoot_1": 1, + "mutate_only_covered_lines.x_hello_mutate_only_covered_lines__nootnoot_2": 1, + "mutate_only_covered_lines.x_hello_mutate_only_covered_lines__nootnoot_3": 1, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_1": 1, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_2": 1, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_3": 1, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_4": 1, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_5": 0, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_6": 0, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_7": 0, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_8": 0, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_9": 0, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_10": 0, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_11": 0, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_12": 0, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_13": 0, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_14": 0, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_15": 0, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_16": 0, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_17": 0, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_18": 0, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_19": 0, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_20": 0, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_21": 0, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_22": 0, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_23": 0, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_24": 1, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_25": 1, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_26": 1, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_27": 1, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_28": 1, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_29": 1, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_30": 1, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_31": 1, + "mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__nootnoot_32": 1 } } \ No newline at end of file diff --git a/tests/e2e/snapshots/my_lib.json b/tests/e2e/snapshots/my_lib.json index e5bc55c0..8a0f8ace 100644 --- a/tests/e2e/snapshots/my_lib.json +++ b/tests/e2e/snapshots/my_lib.json @@ -1,69 +1,69 @@ { "mutants/src/my_lib/__init__.py.meta": { - "my_lib.x_hello__mutmut_1": 1, - "my_lib.x_hello__mutmut_2": 1, - "my_lib.x_hello__mutmut_3": 1, - "my_lib.x_badly_tested__mutmut_1": 0, - "my_lib.x_badly_tested__mutmut_2": 0, - "my_lib.x_badly_tested__mutmut_3": 0, - "my_lib.x_untested__mutmut_1": 33, - "my_lib.x_untested__mutmut_2": 33, - "my_lib.x_untested__mutmut_3": 33, - "my_lib.x_make_greeter__mutmut_1": 1, - "my_lib.x_make_greeter__mutmut_2": 1, - "my_lib.x_make_greeter__mutmut_3": 1, - "my_lib.x_make_greeter__mutmut_4": 1, - "my_lib.x_make_greeter__mutmut_5": 0, - "my_lib.x_make_greeter__mutmut_6": 0, - "my_lib.x_make_greeter__mutmut_7": 0, - "my_lib.x_fibonacci__mutmut_1": 1, - "my_lib.x_fibonacci__mutmut_2": 0, - "my_lib.x_fibonacci__mutmut_3": 0, - "my_lib.x_fibonacci__mutmut_4": 0, - "my_lib.x_fibonacci__mutmut_5": 0, - "my_lib.x_fibonacci__mutmut_6": 0, - "my_lib.x_fibonacci__mutmut_7": 0, - "my_lib.x_fibonacci__mutmut_8": 0, - "my_lib.x_fibonacci__mutmut_9": 0, - "my_lib.x_async_consumer__mutmut_1": 1, - "my_lib.x_async_consumer__mutmut_2": 1, - "my_lib.x_async_generator__mutmut_1": 1, - "my_lib.x_async_generator__mutmut_2": 1, - "my_lib.x_simple_consumer__mutmut_1": 1, - "my_lib.x_simple_consumer__mutmut_2": 1, - "my_lib.x_simple_consumer__mutmut_3": 1, - "my_lib.x_simple_consumer__mutmut_4": 1, - "my_lib.x_simple_consumer__mutmut_5": 1, - "my_lib.x_simple_consumer__mutmut_6": 0, - "my_lib.x_simple_consumer__mutmut_7": 1, - "my_lib.x_double_generator__mutmut_1": 1, - "my_lib.x_double_generator__mutmut_2": 1, - "my_lib.x_double_generator__mutmut_3": 0, - "my_lib.x_double_generator__mutmut_4": 0, - "my_lib.x\u01c1Point\u01c1__init____mutmut_1": 1, - "my_lib.x\u01c1Point\u01c1__init____mutmut_2": 1, - "my_lib.x\u01c1Point\u01c1abs__mutmut_1": 33, - "my_lib.x\u01c1Point\u01c1abs__mutmut_2": 33, - "my_lib.x\u01c1Point\u01c1abs__mutmut_3": 33, - "my_lib.x\u01c1Point\u01c1abs__mutmut_4": 33, - "my_lib.x\u01c1Point\u01c1abs__mutmut_5": 33, - "my_lib.x\u01c1Point\u01c1abs__mutmut_6": 33, - "my_lib.x\u01c1Point\u01c1add__mutmut_1": 0, - "my_lib.x\u01c1Point\u01c1add__mutmut_2": 1, - "my_lib.x\u01c1Point\u01c1add__mutmut_3": 1, - "my_lib.x\u01c1Point\u01c1add__mutmut_4": 0, - "my_lib.x\u01c1Point\u01c1to_origin__mutmut_1": 1, - "my_lib.x\u01c1Point\u01c1to_origin__mutmut_2": 1, - "my_lib.x\u01c1Point\u01c1to_origin__mutmut_3": 0, - "my_lib.x\u01c1Point\u01c1to_origin__mutmut_4": 0, - "my_lib.x\u01c1Point\u01c1__len____mutmut_1": 33, - "my_lib.x_escape_sequences__mutmut_1": 1, - "my_lib.x_escape_sequences__mutmut_2": 0, - "my_lib.x_escape_sequences__mutmut_3": 1, - "my_lib.x_escape_sequences__mutmut_4": 0, - "my_lib.x_escape_sequences__mutmut_5": 0, - "my_lib.x_create_a_segfault_when_mutated__mutmut_1": -11, - "my_lib.x_create_a_segfault_when_mutated__mutmut_2": 0, - "my_lib.x_create_a_segfault_when_mutated__mutmut_3": 0 + "my_lib.x_hello__nootnoot_1": 1, + "my_lib.x_hello__nootnoot_2": 1, + "my_lib.x_hello__nootnoot_3": 1, + "my_lib.x_badly_tested__nootnoot_1": 0, + "my_lib.x_badly_tested__nootnoot_2": 0, + "my_lib.x_badly_tested__nootnoot_3": 0, + "my_lib.x_untested__nootnoot_1": 33, + "my_lib.x_untested__nootnoot_2": 33, + "my_lib.x_untested__nootnoot_3": 33, + "my_lib.x_make_greeter__nootnoot_1": 1, + "my_lib.x_make_greeter__nootnoot_2": 1, + "my_lib.x_make_greeter__nootnoot_3": 1, + "my_lib.x_make_greeter__nootnoot_4": 1, + "my_lib.x_make_greeter__nootnoot_5": 0, + "my_lib.x_make_greeter__nootnoot_6": 0, + "my_lib.x_make_greeter__nootnoot_7": 0, + "my_lib.x_fibonacci__nootnoot_1": 1, + "my_lib.x_fibonacci__nootnoot_2": 0, + "my_lib.x_fibonacci__nootnoot_3": 0, + "my_lib.x_fibonacci__nootnoot_4": 0, + "my_lib.x_fibonacci__nootnoot_5": 0, + "my_lib.x_fibonacci__nootnoot_6": 0, + "my_lib.x_fibonacci__nootnoot_7": 0, + "my_lib.x_fibonacci__nootnoot_8": 0, + "my_lib.x_fibonacci__nootnoot_9": 0, + "my_lib.x_async_consumer__nootnoot_1": 1, + "my_lib.x_async_consumer__nootnoot_2": 1, + "my_lib.x_async_generator__nootnoot_1": 1, + "my_lib.x_async_generator__nootnoot_2": 1, + "my_lib.x_simple_consumer__nootnoot_1": 1, + "my_lib.x_simple_consumer__nootnoot_2": 1, + "my_lib.x_simple_consumer__nootnoot_3": 1, + "my_lib.x_simple_consumer__nootnoot_4": 1, + "my_lib.x_simple_consumer__nootnoot_5": 1, + "my_lib.x_simple_consumer__nootnoot_6": 0, + "my_lib.x_simple_consumer__nootnoot_7": 1, + "my_lib.x_double_generator__nootnoot_1": 1, + "my_lib.x_double_generator__nootnoot_2": 1, + "my_lib.x_double_generator__nootnoot_3": 0, + "my_lib.x_double_generator__nootnoot_4": 0, + "my_lib.x\u01c1Point\u01c1__init____nootnoot_1": 1, + "my_lib.x\u01c1Point\u01c1__init____nootnoot_2": 1, + "my_lib.x\u01c1Point\u01c1abs__nootnoot_1": 33, + "my_lib.x\u01c1Point\u01c1abs__nootnoot_2": 33, + "my_lib.x\u01c1Point\u01c1abs__nootnoot_3": 33, + "my_lib.x\u01c1Point\u01c1abs__nootnoot_4": 33, + "my_lib.x\u01c1Point\u01c1abs__nootnoot_5": 33, + "my_lib.x\u01c1Point\u01c1abs__nootnoot_6": 33, + "my_lib.x\u01c1Point\u01c1add__nootnoot_1": 0, + "my_lib.x\u01c1Point\u01c1add__nootnoot_2": 1, + "my_lib.x\u01c1Point\u01c1add__nootnoot_3": 1, + "my_lib.x\u01c1Point\u01c1add__nootnoot_4": 0, + "my_lib.x\u01c1Point\u01c1to_origin__nootnoot_1": 1, + "my_lib.x\u01c1Point\u01c1to_origin__nootnoot_2": 1, + "my_lib.x\u01c1Point\u01c1to_origin__nootnoot_3": 0, + "my_lib.x\u01c1Point\u01c1to_origin__nootnoot_4": 0, + "my_lib.x\u01c1Point\u01c1__len____nootnoot_1": 33, + "my_lib.x_escape_sequences__nootnoot_1": 1, + "my_lib.x_escape_sequences__nootnoot_2": 0, + "my_lib.x_escape_sequences__nootnoot_3": 1, + "my_lib.x_escape_sequences__nootnoot_4": 0, + "my_lib.x_escape_sequences__nootnoot_5": 0, + "my_lib.x_create_a_segfault_when_mutated__nootnoot_1": -11, + "my_lib.x_create_a_segfault_when_mutated__nootnoot_2": 0, + "my_lib.x_create_a_segfault_when_mutated__nootnoot_3": 0 } } \ No newline at end of file diff --git a/tests/e2e/test_cli_json_output.py b/tests/e2e/test_cli_json_output.py new file mode 100644 index 00000000..b7cb7995 --- /dev/null +++ b/tests/e2e/test_cli_json_output.py @@ -0,0 +1,41 @@ +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + + +def test_run_json_output_is_machine_readable(): + project_path = Path("..").parent / "e2e_projects" / "config" + mutants_path = project_path / "mutants" + shutil.rmtree(mutants_path, ignore_errors=True) + + result = subprocess.run( + [ + sys.executable, + "-m", + "nootnoot", + "run", + "--format", + "json", + "--max-children", + "1", + ], + cwd=project_path, + check=False, + text=True, + capture_output=True, + env=os.environ.copy(), + ) + + assert result.returncode == 0 + payload = json.loads(result.stdout) + + assert payload["schema_version"] == 1 + assert "summary" in payload + assert "mutants" in payload + assert "events" in payload + event_names = {event["event"] for event in payload["events"]} + assert "session_started" in event_names + assert "session_finished" in event_names diff --git a/tests/e2e/test_cli_version.py b/tests/e2e/test_cli_version.py index 36893715..b421120f 100644 --- a/tests/e2e/test_cli_version.py +++ b/tests/e2e/test_cli_version.py @@ -1,7 +1,7 @@ from click.testing import CliRunner -from mutmut import __version__ -from mutmut.__main__ import cli +from nootnoot import __version__ +from nootnoot.cli import cli def test_cli_version(): diff --git a/tests/e2e/test_e2e_result_snapshots.py b/tests/e2e/test_e2e_result_snapshots.py index d510e72f..87175eca 100644 --- a/tests/e2e/test_e2e_result_snapshots.py +++ b/tests/e2e/test_e2e_result_snapshots.py @@ -5,8 +5,11 @@ from pathlib import Path from typing import Any -import mutmut -from mutmut.__main__ import SourceFileMutationData, _run, ensure_config_loaded, walk_source_files +from nootnoot.app.config import ensure_config_loaded +from nootnoot.app.meta import SourceFileMutationData +from nootnoot.app.mutation import walk_source_files +from nootnoot.app.state import NootNootState +from nootnoot.cli import _run @contextmanager @@ -19,44 +22,46 @@ def change_cwd(path): os.chdir(old_cwd) -def read_all_stats_for_project(project_path: Path) -> dict[str, dict]: - """Create a single dict from all mutant results in *.meta files""" +def read_all_stats_for_project(project_path: Path, state: NootNootState) -> dict[str, dict]: + """Create a single dict from all mutant results in *.meta files.""" with change_cwd(project_path): - ensure_config_loaded() + ensure_config_loaded(state) stats = {} - for p in walk_source_files(): - if mutmut.config.should_ignore_for_mutation(p): # type: ignore + config = state.config + debug = config.debug if config else False + for p in walk_source_files(state): + if config is not None and config.should_ignore_for_mutation(p): continue data = SourceFileMutationData(path=p) - data.load() + data.load(debug=debug) stats[str(data.meta_path)] = data.exit_code_by_key return stats -def read_json_file(path: Path): - with open(path, 'r') as file: +def read_json_file(path: Path) -> Any: + with Path(path).open("r", encoding="utf-8") as file: return json.load(file) -def write_json_file(path: Path, data: Any): - with open(path, 'w') as file: +def write_json_file(path: Path, data: Any) -> None: + with Path(path).open("w", encoding="utf-8") as file: json.dump(data, file, indent=2) -def asserts_results_did_not_change(project: str): - """Runs mutmut on this project and verifies that the results stay the same for all mutations.""" +def asserts_results_did_not_change(project: str, state: NootNootState) -> None: + """Run nootnoot on this project and verify that the results stay the same for all mutations.""" project_path = Path("..").parent / "e2e_projects" / project mutants_path = project_path / "mutants" shutil.rmtree(mutants_path, ignore_errors=True) - # mutmut run + # nootnoot run with change_cwd(project_path): - _run([], None) + _run(state, [], None) - results = read_all_stats_for_project(project_path) + results = read_all_stats_for_project(project_path, state) snapshot_path = Path("tests") / "e2e" / "snapshots" / (project + ".json") @@ -64,23 +69,23 @@ def asserts_results_did_not_change(project: str): # compare results against previous snapshot previous_snapshot = read_json_file(snapshot_path) - err_msg = f'Mutmut results changed for the E2E project \'{project}\'. If this change was on purpose, delete {snapshot_path} and rerun the tests.' + err_msg = ( + f"NootNoot results changed for the E2E project '{project}'. " + f"If this change was on purpose, delete {snapshot_path} and rerun the tests." + ) assert results == previous_snapshot, err_msg else: # create the first snapshot write_json_file(snapshot_path, results) -def test_my_lib_result_snapshot(): - mutmut._reset_globals() - asserts_results_did_not_change("my_lib") +def test_my_lib_result_snapshot(nootnoot_state): + asserts_results_did_not_change("my_lib", nootnoot_state) -def test_config_result_snapshot(): - mutmut._reset_globals() - asserts_results_did_not_change("config") +def test_config_result_snapshot(nootnoot_state): + asserts_results_did_not_change("config", nootnoot_state) -def test_mutate_only_covered_lines_result_snapshot(): - mutmut._reset_globals() - asserts_results_did_not_change("mutate_only_covered_lines") \ No newline at end of file +def test_mutate_only_covered_lines_result_snapshot(nootnoot_state): + asserts_results_did_not_change("mutate_only_covered_lines", nootnoot_state) diff --git a/tests/test_generation_error_handling.py b/tests/test_generation_error_handling.py index 05c32c8f..947ab898 100644 --- a/tests/test_generation_error_handling.py +++ b/tests/test_generation_error_handling.py @@ -2,11 +2,10 @@ import pytest -import mutmut -import mutmut.__main__ -from mutmut.__main__ import InvalidGeneratedSyntaxException, create_mutants +import nootnoot.app.mutation as nootnoot_mutation +from nootnoot.app.mutation import InvalidGeneratedSyntaxException, create_mutants -source_dir = Path(__file__).parent / 'data' / 'test_generation' +source_dir = Path(__file__).parent / "data" / "test_generation" source_dir = source_dir.relative_to(Path.cwd()) @@ -15,9 +14,8 @@ def should_ignore_for_mutation(self, path: Path) -> bool: return False -def test_mutant_generation_raises_exception_on_invalid_syntax(monkeypatch): - mutmut._reset_globals() - mutmut.config = MockConfig() +def test_mutant_generation_raises_exception_on_invalid_syntax(nootnoot_state, monkeypatch): + nootnoot_state.config = MockConfig() source_files = [ source_dir / "valid_syntax_1.py", @@ -26,13 +24,10 @@ def test_mutant_generation_raises_exception_on_invalid_syntax(monkeypatch): source_dir / "valid_syntax_4.py", source_dir / "invalid_syntax.py", ] - monkeypatch.setattr(mutmut.__main__, "walk_source_files", lambda: source_files) - monkeypatch.setattr("mutmut.config.should_ignore_for_mutation", lambda _path: False) + monkeypatch.setattr(nootnoot_mutation, "walk_source_files", lambda _state: source_files) # should raise an exception, because we copy the invalid_syntax.py file and then verify # if it is valid syntax - with pytest.raises(InvalidGeneratedSyntaxException) as excinfo: - # should raise a warning, because libcst is not able to parse invalid_syntax.py - with pytest.warns(SyntaxWarning): - create_mutants(max_children=2) - assert 'invalid_syntax.py' in str(excinfo.value) + with pytest.raises(InvalidGeneratedSyntaxException) as excinfo, pytest.warns(SyntaxWarning): + create_mutants(max_children=2, state=nootnoot_state) + assert "invalid_syntax.py" in str(excinfo.value) diff --git a/tests/test_mutation.py b/tests/test_mutation.py index bce36b98..e7697dc3 100644 --- a/tests/test_mutation.py +++ b/tests/test_mutation.py @@ -1,156 +1,255 @@ import os -from typing import Union +from pathlib import Path +from textwrap import dedent +from typing import cast from unittest.mock import Mock, patch import libcst as cst import pytest -import mutmut -from mutmut.__main__ import ( - CLASS_NAME_SEPARATOR, - CatchOutput, - MutmutProgrammaticFailException, +from nootnoot.app.mutation import ( + NootNootProgrammaticFailException, get_diff_for_mutant, orig_function_and_class_names_from_key, - run_forced_fail_test, ) -from mutmut.file_mutation import create_mutations, mutate_file_contents -from mutmut.trampoline_templates import mangle_function_name, trampoline_impl +from nootnoot.cli import CatchOutput, run_forced_fail_test +from nootnoot.core.file_mutation import create_mutations, mutate_file_contents +from nootnoot.core.trampoline_templates import CLASS_NAME_SEPARATOR, mangle_function_name -def mutants_for_source(source: str, covered_lines: Union[set[int], None] = None) -> list[str]: +def mutants_for_source(source: str, covered_lines: set[int] | None = None) -> list[str]: module, mutated_nodes = create_mutations(source, covered_lines) - mutants: list[str] = [module.deep_replace(m.original_node, m.mutated_node).code for m in mutated_nodes] # type: ignore - + mutants: list[str] = [ + cast("cst.Module", module.deep_replace(m.original_node, m.mutated_node)).code for m in mutated_nodes + ] return mutants def mutated_module(source: str) -> str: - mutated_code, _ = mutate_file_contents('', source) + mutated_code, _ = mutate_file_contents("", source) return mutated_code +@pytest.fixture +def mock_catch_output(): + with ( + patch.object(CatchOutput, "dump_output"), + patch.object(CatchOutput, "stop"), + patch.object(CatchOutput, "start"), + ): + yield + + @pytest.mark.parametrize( - 'original, expected', [ - ('foo(a, *args, **kwargs)', [ - 'foo(*args, **kwargs)', - 'foo(None, *args, **kwargs)', - 'foo(a, **kwargs)', - 'foo(a, *args, )', - ]), + ("original", "expected"), + [ + ( + "foo(a, *args, **kwargs)", + [ + "foo(*args, **kwargs)", + "foo(None, *args, **kwargs)", + "foo(a, **kwargs)", + "foo(a, *args, )", + ], + ), # ('break', 'continue'), # probably a bad idea. Can introduce infinite loops. - ('break', 'return'), - ('continue', 'break'), - ('a.lower()', 'a.upper()'), - ('a.upper()', 'a.lower()'), - ('a.b.lower()', 'a.b.upper()'), - ('a.b.upper()', 'a.b.lower()'), - ('a.lstrip("!")', ['a.rstrip("!")', 'a.lstrip("XX!XX")', 'a.lstrip(None)']), - ('a.rstrip("!")', ['a.lstrip("!")', 'a.rstrip("XX!XX")', 'a.rstrip(None)']), - ('a.find("!")', ['a.rfind("!")', 'a.find("XX!XX")', 'a.find(None)']), - ('a.rfind("!")', ['a.find("!")', 'a.rfind("XX!XX")', 'a.rfind(None)']), - ('a.ljust(10, "+")', [ - 'a.ljust("+")', 'a.ljust(10, "XX+XX")', - 'a.ljust(10, )', 'a.ljust(10, None)', - 'a.ljust(11, "+")', 'a.ljust(None, "+")', - 'a.rjust(10, "+")' - ]), - ('a.rjust(10, "+")', [ - 'a.ljust(10, "+")', 'a.rjust("+")', - 'a.rjust(10, "XX+XX")', 'a.rjust(10, )', - 'a.rjust(10, None)', 'a.rjust(11, "+")', - 'a.rjust(None, "+")' - ]), - ('a.index("+")', ['a.rindex("+")', 'a.index("XX+XX")', 'a.index(None)']), - ('a.rindex("+")', ['a.index("+")', 'a.rindex("XX+XX")', 'a.rindex(None)']), - ('a.split()', []), - ('a.rsplit()', []), - ('a.split(" ")', ['a.split("XX XX")', 'a.split(None)']), - ('a.rsplit(" ")', ['a.rsplit("XX XX")', 'a.rsplit(None)']), - ('a.split(sep="")', ['a.split(sep="XXXX")', 'a.split(sep=None)']), - ('a.rsplit(sep="")', ['a.rsplit(sep="XXXX")', 'a.rsplit(sep=None)']), - ('a.split(maxsplit=-1)', [ - 'a.rsplit(maxsplit=-1)', 'a.split(maxsplit=+1)', 'a.split(maxsplit=-2)', 'a.split(maxsplit=None)' - ]), - ('a.rsplit(maxsplit=-1)', [ - 'a.split(maxsplit=-1)', 'a.rsplit(maxsplit=+1)', 'a.rsplit(maxsplit=-2)', 'a.rsplit(maxsplit=None)' - ]), - ('a.split(" ", maxsplit=-1)', [ - 'a.split(" ", )', 'a.split(" ", maxsplit=+1)', 'a.split(" ", maxsplit=-2)', - 'a.split(" ", maxsplit=None)', 'a.split("XX XX", maxsplit=-1)', 'a.split(None, maxsplit=-1)', - 'a.split(maxsplit=-1)', 'a.rsplit(" ", maxsplit=-1)' - ]), - ('a.rsplit(" ", maxsplit=-1)', [ - 'a.rsplit(" ", )', 'a.rsplit(" ", maxsplit=+1)', 'a.rsplit(" ", maxsplit=-2)', - 'a.rsplit(" ", maxsplit=None)', 'a.rsplit("XX XX", maxsplit=-1)', 'a.rsplit(None, maxsplit=-1)', - 'a.rsplit(maxsplit=-1)', 'a.split(" ", maxsplit=-1)' - ]), - ('a.split(maxsplit=1)', ['a.split(maxsplit=2)', 'a.split(maxsplit=None)', 'a.rsplit(maxsplit=1)']), - ('a.rsplit(maxsplit=1)', ['a.rsplit(maxsplit=2)', 'a.rsplit(maxsplit=None)', 'a.split(maxsplit=1)']), - ('a.split(" ", 1)', [ - 'a.rsplit(" ", 1)', 'a.split(" ", )', 'a.split(" ", 2)', 'a.split(" ", None)', - 'a.split("XX XX", 1)', 'a.split(1)', 'a.split(None, 1)' - ]), - ('a.rsplit(" ", 1)', [ - 'a.rsplit(" ", )', 'a.rsplit(" ", 2)', 'a.rsplit(" ", None)', 'a.rsplit("XX XX", 1)', - 'a.rsplit(1)', 'a.rsplit(None, 1)', 'a.split(" ", 1)' - ]), - ('a.split(" ", maxsplit=1)', [ - 'a.rsplit(" ", maxsplit=1)', 'a.split(" ", )', 'a.split(" ", maxsplit=2)', 'a.split(" ", maxsplit=None)', - 'a.split("XX XX", maxsplit=1)', 'a.split(None, maxsplit=1)', 'a.split(maxsplit=1)' - ]), - ('a.rsplit(" ", maxsplit=1)', [ - 'a.rsplit(" ", )', 'a.rsplit(" ", maxsplit=2)', 'a.rsplit(" ", maxsplit=None)', - 'a.rsplit("XX XX", maxsplit=1)', 'a.rsplit(None, maxsplit=1)', 'a.rsplit(maxsplit=1)', - 'a.split(" ", maxsplit=1)' - ]), - ('a.removeprefix("+")', ['a.removesuffix("+")', 'a.removeprefix("XX+XX")', 'a.removeprefix(None)']), - ('a.removesuffix("+")', ['a.removeprefix("+")', 'a.removesuffix("XX+XX")', 'a.removesuffix(None)']), - ('a.partition("++")', ['a.rpartition("++")', 'a.partition("XX++XX")', 'a.partition(None)']), - ('a.rpartition("++")', ['a.partition("++")', 'a.rpartition("XX++XX")', 'a.rpartition(None)']), - ('a(b)', 'a(None)'), + ("break", "return"), + ("continue", "break"), + ("a.lower()", "a.upper()"), + ("a.upper()", "a.lower()"), + ("a.b.lower()", "a.b.upper()"), + ("a.b.upper()", "a.b.lower()"), + ('a.lstrip("!")', ['a.rstrip("!")', 'a.lstrip("XX!XX")', "a.lstrip(None)"]), + ('a.rstrip("!")', ['a.lstrip("!")', 'a.rstrip("XX!XX")', "a.rstrip(None)"]), + ('a.find("!")', ['a.rfind("!")', 'a.find("XX!XX")', "a.find(None)"]), + ('a.rfind("!")', ['a.find("!")', 'a.rfind("XX!XX")', "a.rfind(None)"]), + ( + 'a.ljust(10, "+")', + [ + 'a.ljust("+")', + 'a.ljust(10, "XX+XX")', + "a.ljust(10, )", + "a.ljust(10, None)", + 'a.ljust(11, "+")', + 'a.ljust(None, "+")', + 'a.rjust(10, "+")', + ], + ), + ( + 'a.rjust(10, "+")', + [ + 'a.ljust(10, "+")', + 'a.rjust("+")', + 'a.rjust(10, "XX+XX")', + "a.rjust(10, )", + "a.rjust(10, None)", + 'a.rjust(11, "+")', + 'a.rjust(None, "+")', + ], + ), + ('a.index("+")', ['a.rindex("+")', 'a.index("XX+XX")', "a.index(None)"]), + ('a.rindex("+")', ['a.index("+")', 'a.rindex("XX+XX")', "a.rindex(None)"]), + ("a.split()", []), + ("a.rsplit()", []), + ('a.split(" ")', ['a.split("XX XX")', "a.split(None)"]), + ('a.rsplit(" ")', ['a.rsplit("XX XX")', "a.rsplit(None)"]), + ('a.split(sep="")', ['a.split(sep="XXXX")', "a.split(sep=None)"]), + ('a.rsplit(sep="")', ['a.rsplit(sep="XXXX")', "a.rsplit(sep=None)"]), + ( + "a.split(maxsplit=-1)", + [ + "a.rsplit(maxsplit=-1)", + "a.split(maxsplit=+1)", + "a.split(maxsplit=-2)", + "a.split(maxsplit=None)", + ], + ), + ( + "a.rsplit(maxsplit=-1)", + [ + "a.split(maxsplit=-1)", + "a.rsplit(maxsplit=+1)", + "a.rsplit(maxsplit=-2)", + "a.rsplit(maxsplit=None)", + ], + ), + ( + 'a.split(" ", maxsplit=-1)', + [ + 'a.split(" ", )', + 'a.split(" ", maxsplit=+1)', + 'a.split(" ", maxsplit=-2)', + 'a.split(" ", maxsplit=None)', + 'a.split("XX XX", maxsplit=-1)', + "a.split(None, maxsplit=-1)", + "a.split(maxsplit=-1)", + 'a.rsplit(" ", maxsplit=-1)', + ], + ), + ( + 'a.rsplit(" ", maxsplit=-1)', + [ + 'a.rsplit(" ", )', + 'a.rsplit(" ", maxsplit=+1)', + 'a.rsplit(" ", maxsplit=-2)', + 'a.rsplit(" ", maxsplit=None)', + 'a.rsplit("XX XX", maxsplit=-1)', + "a.rsplit(None, maxsplit=-1)", + "a.rsplit(maxsplit=-1)", + 'a.split(" ", maxsplit=-1)', + ], + ), + ("a.split(maxsplit=1)", ["a.split(maxsplit=2)", "a.split(maxsplit=None)", "a.rsplit(maxsplit=1)"]), + ("a.rsplit(maxsplit=1)", ["a.rsplit(maxsplit=2)", "a.rsplit(maxsplit=None)", "a.split(maxsplit=1)"]), + ( + 'a.split(" ", 1)', + [ + 'a.rsplit(" ", 1)', + 'a.split(" ", )', + 'a.split(" ", 2)', + 'a.split(" ", None)', + 'a.split("XX XX", 1)', + "a.split(1)", + "a.split(None, 1)", + ], + ), + ( + 'a.rsplit(" ", 1)', + [ + 'a.rsplit(" ", )', + 'a.rsplit(" ", 2)', + 'a.rsplit(" ", None)', + 'a.rsplit("XX XX", 1)', + "a.rsplit(1)", + "a.rsplit(None, 1)", + 'a.split(" ", 1)', + ], + ), + ( + 'a.split(" ", maxsplit=1)', + [ + 'a.rsplit(" ", maxsplit=1)', + 'a.split(" ", )', + 'a.split(" ", maxsplit=2)', + 'a.split(" ", maxsplit=None)', + 'a.split("XX XX", maxsplit=1)', + "a.split(None, maxsplit=1)", + "a.split(maxsplit=1)", + ], + ), + ( + 'a.rsplit(" ", maxsplit=1)', + [ + 'a.rsplit(" ", )', + 'a.rsplit(" ", maxsplit=2)', + 'a.rsplit(" ", maxsplit=None)', + 'a.rsplit("XX XX", maxsplit=1)', + "a.rsplit(None, maxsplit=1)", + "a.rsplit(maxsplit=1)", + 'a.split(" ", maxsplit=1)', + ], + ), + ('a.removeprefix("+")', ['a.removesuffix("+")', 'a.removeprefix("XX+XX")', "a.removeprefix(None)"]), + ('a.removesuffix("+")', ['a.removeprefix("+")', 'a.removesuffix("XX+XX")', "a.removesuffix(None)"]), + ('a.partition("++")', ['a.rpartition("++")', 'a.partition("XX++XX")', "a.partition(None)"]), + ('a.rpartition("++")', ['a.partition("++")', 'a.rpartition("XX++XX")', "a.rpartition(None)"]), + ("a(b)", "a(None)"), ("dict(a=None)", ["dict(aXX=None)"]), - ("dict(a=b)", ["dict(aXX=b)", 'dict(a=None)']), - ('lambda **kwargs: Variable.integer(**setdefaults(kwargs, dict(show=False)))', [ - 'lambda **kwargs: Variable.integer(**setdefaults(kwargs, dict(show=True)))', - 'lambda **kwargs: Variable.integer(**setdefaults(kwargs, dict(show=None)))', - 'lambda **kwargs: Variable.integer(**setdefaults(kwargs, dict(showXX=False)))', - 'lambda **kwargs: Variable.integer(**setdefaults(None, dict(show=False)))', - 'lambda **kwargs: Variable.integer(**setdefaults(kwargs, None))', - 'lambda **kwargs: Variable.integer(**setdefaults(kwargs, ))', - 'lambda **kwargs: Variable.integer(**setdefaults(dict(show=False)))', - # TODO: this mutant would exist if we also mutate single-arg arglists (see implementation) - # 'lambda **kwargs: Variable.integer()', - 'lambda **kwargs: None', - ]), - ('x: list[A | None]', []), - ('a: Optional[int] = None', 'a: Optional[int] = ""'), - ('a: int = 1', ['a: int = 2', 'a: int = None']), - ('a: str = "FoO"', ['a: str = "XXFoOXX"', 'a: str = "foo"', 'a: str = "FOO"', 'a: str = None']), - (r'a: str = "Fo\t"', [r'a: str = "XXFo\tXX"', r'a: str = "FO\t"', r'a: str = "fo\t"', 'a: str = None']), - (r'a: str = "Fo\N{ghost} \U11223344"', [r'a: str = "XXFo\N{ghost} \U11223344XX"', r'a: str = "FO\N{GHOST} \U11223344"', r'a: str = "fo\N{ghost} \U11223344"', 'a: str = None']), - ('lambda: 0', ['lambda: 1', 'lambda: None']), - ("1 in (1, 2)", ['2 in (1, 2)', '1 not in (1, 2)', '1 in (2, 2)', '1 in (1, 3)']), - ('1+1', ['2+1', '1 - 1', '1+2']), - ('1', '2'), - ('1-1', ['2-1', '1 + 1', '1-2']), - ('1*1', ['2*1', '1 / 1', '1*2']), - ('1/1', ['2/1', '1 * 1', '1/2']), - ('1//1', ['2//1', '1 / 1', '1//2']), - ('1%1', ['2%1', '1 / 1', '1%2']), - ('1<<1', ['2<<1', '1 >> 1', '1<<2']), - ('1>>1', ['2>>1', '1 << 1', '1>>2']), - ('a&b', ['a | b']), - ('a|b', ['a & b']), - ('a^b', ['a & b']), - ('a**b', ['a * b']), - ('~a', ['a']), + ("dict(a=b)", ["dict(aXX=b)", "dict(a=None)"]), + ( + "lambda **kwargs: Variable.integer(**setdefaults(kwargs, dict(show=False)))", + [ + "lambda **kwargs: Variable.integer(**setdefaults(kwargs, dict(show=True)))", + "lambda **kwargs: Variable.integer(**setdefaults(kwargs, dict(show=None)))", + "lambda **kwargs: Variable.integer(**setdefaults(kwargs, dict(showXX=False)))", + "lambda **kwargs: Variable.integer(**setdefaults(None, dict(show=False)))", + "lambda **kwargs: Variable.integer(**setdefaults(kwargs, None))", + "lambda **kwargs: Variable.integer(**setdefaults(kwargs, ))", + "lambda **kwargs: Variable.integer(**setdefaults(dict(show=False)))", + # TODO: this mutant would exist if we also mutate single-arg arglists (see implementation) + # 'lambda **kwargs: Variable.integer()', + "lambda **kwargs: None", + ], + ), + ("x: list[A | None]", []), + ("a: Optional[int] = None", 'a: Optional[int] = ""'), + ("a: int = 1", ["a: int = 2", "a: int = None"]), + ('a: str = "FoO"', ['a: str = "XXFoOXX"', 'a: str = "foo"', 'a: str = "FOO"', "a: str = None"]), + ( + r'a: str = "Fo\t"', + [r'a: str = "XXFo\tXX"', r'a: str = "FO\t"', r'a: str = "fo\t"', "a: str = None"], + ), + ( + r'a: str = "Fo\N{ghost} \U11223344"', + [ + r'a: str = "XXFo\N{ghost} \U11223344XX"', + r'a: str = "FO\N{GHOST} \U11223344"', + r'a: str = "fo\N{ghost} \U11223344"', + "a: str = None", + ], + ), + ("lambda: 0", ["lambda: 1", "lambda: None"]), + ("1 in (1, 2)", ["2 in (1, 2)", "1 not in (1, 2)", "1 in (2, 2)", "1 in (1, 3)"]), + ("1+1", ["2+1", "1 - 1", "1+2"]), + ("1", "2"), + ("1-1", ["2-1", "1 + 1", "1-2"]), + ("1*1", ["2*1", "1 / 1", "1*2"]), + ("1/1", ["2/1", "1 * 1", "1/2"]), + ("1//1", ["2//1", "1 / 1", "1//2"]), + ("1%1", ["2%1", "1 / 1", "1%2"]), + ("1<<1", ["2<<1", "1 >> 1", "1<<2"]), + ("1>>1", ["2>>1", "1 << 1", "1>>2"]), + ("a&b", ["a | b"]), + ("a|b", ["a & b"]), + ("a^b", ["a & b"]), + ("a**b", ["a * b"]), + ("~a", ["a"]), # ('1.0', '1.0000000000000002'), # using numpy features - ('1.0', '2.0'), - ('0.1', '1.1'), - ('1e-3', '1.001'), - ('True', 'False'), - ('False', 'True'), + ("1.0", "2.0"), + ("0.1", "1.1"), + ("1e-3", "1.001"), + ("True", "False"), + ("False", "True"), ('"FoO"', ['"XXFoOXX"', '"foo"', '"FOO"']), ("'FoO'", ["'XXFoOXX'", "'foo'", "'FOO'"]), ("u'FoO'", ["u'XXFoOXX'", "u'foo'", "u'FOO'"]), @@ -159,72 +258,73 @@ def mutated_module(source: str) -> str: ("0o10", "9"), ("0x10", "17"), ("0b10", "3"), - ("1<2", ['2<2', '1 <= 2', '1<3']), - ('(1, 2)', ['(2, 2)', '(1, 3)']), - ("1 not in (1, 2)", ['2 not in (1, 2)', '1 in (1, 2)', '1 not in (2, 2)', '1 not in (1, 3)']), # two spaces here because "not in" is two words + ("1<2", ["2<2", "1 <= 2", "1<3"]), + ("(1, 2)", ["(2, 2)", "(1, 3)"]), + ( + "1 not in (1, 2)", + ["2 not in (1, 2)", "1 in (1, 2)", "1 not in (2, 2)", "1 not in (1, 3)"], + ), # two spaces here because "not in" is two words ("foo is foo", "foo is not foo"), ("foo is not foo", "foo is foo"), - ('a or b', 'a and b'), - ('a and b', 'a or b'), - ('not a', 'a'), - ('a < b', ['a <= b']), - ('a <= b', ['a < b']), - ('a > b', ['a >= b']), - ('a >= b', ['a > b']), - ('a == b', ['a != b']), - ('a != b', ['a == b']), - ('a = b', 'a = None'), - ('a = b = c = x', 'a = b = c = None'), - + ("a or b", "a and b"), + ("a and b", "a or b"), + ("not a", "a"), + ("a < b", ["a <= b"]), + ("a <= b", ["a < b"]), + ("a > b", ["a >= b"]), + ("a >= b", ["a > b"]), + ("a == b", ["a != b"]), + ("a != b", ["a == b"]), + ("a = b", "a = None"), + ("a = b = c = x", "a = b = c = None"), # subscript - ('a[None]', []), - ('a[b]', []), - ('s[0]', ['s[1]']), - ('s[0] = a', ['s[1] = a', 's[0] = None']), - ('s[1:]', ['s[2:]']), - ('s[1:2]', ['s[2:2]', 's[1:3]']), - - ('1j', '2j'), - ('1.0j', '2j'), - ('0o1', '2'), - ('1.0e10', '10000000001.0'), - ('a = {x for x in y}', 'a = None'), - ('x+=1', ['x = 1', 'x -= 1', 'x+=2']), - ('x-=1', ['x = 1', 'x += 1', 'x-=2']), - ('x*=1', ['x = 1', 'x /= 1', 'x*=2']), - ('x/=1', ['x = 1', 'x *= 1', 'x/=2']), - ('x//=1', ['x = 1', 'x /= 1', 'x//=2']), - ('x%=1', ['x = 1', 'x /= 1', 'x%=2']), - ('x<<=1', ['x = 1', 'x >>= 1', 'x<<=2']), - ('x>>=1', ['x = 1', 'x <<= 1', 'x>>=2']), - ('x&=1', ['x = 1', 'x |= 1', 'x&=2']), - ('x|=1', ['x = 1', 'x &= 1', 'x|=2']), - ('x^=1', ['x = 1', 'x &= 1', 'x^=2']), - ('x**=1', ['x = 1', 'x *= 1', 'x**=2']), - ('def foo(s: Int = 1): pass', 'def foo(s: Int = 2): pass'), + ("a[None]", []), + ("a[b]", []), + ("s[0]", ["s[1]"]), + ("s[0] = a", ["s[1] = a", "s[0] = None"]), + ("s[1:]", ["s[2:]"]), + ("s[1:2]", ["s[2:2]", "s[1:3]"]), + ("1j", "2j"), + ("1.0j", "2j"), + ("0o1", "2"), + ("1.0e10", "10000000001.0"), + ("a = {x for x in y}", "a = None"), + ("x+=1", ["x = 1", "x -= 1", "x+=2"]), + ("x-=1", ["x = 1", "x += 1", "x-=2"]), + ("x*=1", ["x = 1", "x /= 1", "x*=2"]), + ("x/=1", ["x = 1", "x *= 1", "x/=2"]), + ("x//=1", ["x = 1", "x /= 1", "x//=2"]), + ("x%=1", ["x = 1", "x /= 1", "x%=2"]), + ("x<<=1", ["x = 1", "x >>= 1", "x<<=2"]), + ("x>>=1", ["x = 1", "x <<= 1", "x>>=2"]), + ("x&=1", ["x = 1", "x |= 1", "x&=2"]), + ("x|=1", ["x = 1", "x &= 1", "x|=2"]), + ("x^=1", ["x = 1", "x &= 1", "x^=2"]), + ("x**=1", ["x = 1", "x *= 1", "x**=2"]), + ("def foo(s: Int = 1): pass", "def foo(s: Int = 2): pass"), # mutating default args with function calls could cause Exceptions at import time ('def foo(a = A("abc")): pass', []), - ('a = None', 'a = ""'), - ('lambda **kwargs: None', 'lambda **kwargs: 0'), - ('lambda: None', 'lambda: 0'), - ('def foo(s: str): pass', []), - ('def foo(a, *, b): pass', []), - ('a(None)', []), + ("a = None", 'a = ""'), + ("lambda **kwargs: None", "lambda **kwargs: 0"), + ("lambda: None", "lambda: 0"), + ("def foo(s: str): pass", []), + ("def foo(a, *, b): pass", []), + ("a(None)", []), ("'''foo'''", []), # don't mutate things we assume to be docstrings ("r'''foo'''", []), # don't mutate things we assume to be docstrings ('"""foo"""', []), # don't mutate things we assume to be docstrings - ('(x for x in [])', []), # don't mutate 'in' in generators - ('from foo import *', []), - ('from .foo import *', []), - ('import foo', []), - ('import foo as bar', []), - ('foo.bar', []), - ('for x in y: pass', []), - ('def foo(a, *args, **kwargs): pass', []), - ('isinstance(a, b)', []), - ('len(a)', []), - ('deepcopy(obj)', ['copy(obj)', 'deepcopy(None)']), - ] + ("(x for x in [])", []), # don't mutate 'in' in generators + ("from foo import *", []), + ("from .foo import *", []), + ("import foo", []), + ("import foo as bar", []), + ("foo.bar", []), + ("for x in y: pass", []), + ("def foo(a, *args, **kwargs): pass", []), + ("isinstance(a, b)", []), + ("len(a)", []), + ("deepcopy(obj)", ["copy(obj)", "deepcopy(None)"]), + ], ) def test_basic_mutations(original, expected): if isinstance(expected, str): @@ -311,9 +411,9 @@ def member(self): mutated_code = mutated_module(source) expected = """class Foo: - def xǁFooǁmember__mutmut_orig(self): + def xǁFooǁmember__nootnoot_orig(self): return 1 - def xǁFooǁmember__mutmut_1(self): + def xǁFooǁmember__nootnoot_1(self): return 2""" assert expected in mutated_code @@ -326,9 +426,9 @@ def test_function_with_annotation(): print(mutated_code) expected_defs = [ - 'def x_capitalize__mutmut_1(s : str):\n return s[0].title() - s[1:] if s else s', - 'def x_capitalize__mutmut_2(s : str):\n return s[1].title() + s[1:] if s else s', - 'def x_capitalize__mutmut_3(s : str):\n return s[0].title() + s[2:] if s else s', + "def x_capitalize__nootnoot_1(s : str):\n return s[0].title() - s[1:] if s else s", + "def x_capitalize__nootnoot_2(s : str):\n return s[1].title() + s[1:] if s else s", + "def x_capitalize__nootnoot_3(s : str):\n return s[0].title() + s[2:] if s else s", ] for expected in expected_defs: @@ -371,17 +471,17 @@ def test_mutate_only_covered_lines_all(): def test_mutate_dict(): - source = 'dict(a=b, c=d)' + source = "dict(a=b, c=d)" mutants = mutants_for_source(source) expected = [ - 'dict(a=None, c=d)', - 'dict(aXX=b, c=d)', - 'dict(a=b, c=None)', - 'dict(a=b, cXX=d)', - 'dict(c=d)', - 'dict(a=b, )', + "dict(a=None, c=d)", + "dict(aXX=b, c=d)", + "dict(a=b, c=None)", + "dict(a=b, cXX=d)", + "dict(c=d)", + "dict(a=b, )", ] assert sorted(mutants) == sorted(expected) @@ -389,7 +489,7 @@ def test_mutate_dict(): def test_syntax_error(): with pytest.raises(cst.ParserSyntaxError): - mutate_file_contents('some_file.py', ':!') + mutate_file_contents("some_file.py", ":!") def test_bug_github_issue_18(): @@ -430,11 +530,11 @@ def from_checker(cls: Type['BaseVisitor'], checker) -> 'BaseVisitor': def test_bug_github_issue_77(): # Don't crash on this - assert mutants_for_source('') == [] + assert mutants_for_source("") == [] def test_bug_github_issue_435(): - source = """ + source = r""" def parse(self, text: str) -> tuple[Tree[Token], str]: text = re.sub(r'[\w\-] [\w\-]', dashrepl, text) @@ -443,16 +543,89 @@ def parse(self, text: str) -> tuple[Tree[Token], str]: mutants = mutants_for_source(source) + def _expected_function(text: str) -> str: + dedented = dedent(text).strip() + lines = dedented.split("\n") + if not lines: + return "" + return "\n".join([ + lines[0], + *(" " + line.lstrip() if line else line for line in lines[1:]), + ]) + expected = [ - 'def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = None\n\n return self.parser.parse(text), text', - 'def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = re.sub(None, dashrepl, text)\n\n return self.parser.parse(text), text', - "def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = re.sub(r'[\\w\\-] [\\w\\-]', None, text)\n\n return self.parser.parse(text), text", - "def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = re.sub(r'[\\w\\-] [\\w\\-]', dashrepl, None)\n\n return self.parser.parse(text), text", - 'def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = re.sub(dashrepl, text)\n\n return self.parser.parse(text), text', - "def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = re.sub(r'[\\w\\-] [\\w\\-]', text)\n\n return self.parser.parse(text), text", - "def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = re.sub(r'[\\w\\-] [\\w\\-]', dashrepl, )\n\n return self.parser.parse(text), text", - "def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = re.sub(r'XX[\\w\\-] [\\w\\-]XX', dashrepl, text)\n\n return self.parser.parse(text), text", - "def parse(self, text: str) -> tuple[Tree[Token], str]:\n text = re.sub(r'[\\w\\-] [\\w\\-]', dashrepl, text)\n\n return self.parser.parse(None), text" + _expected_function( + """ + def parse(self, text: str) -> tuple[Tree[Token], str]: + text = None + + return self.parser.parse(text), text + """ + ), + _expected_function( + """ + def parse(self, text: str) -> tuple[Tree[Token], str]: + text = re.sub(None, dashrepl, text) + + return self.parser.parse(text), text + """ + ), + _expected_function( + r""" + def parse(self, text: str) -> tuple[Tree[Token], str]: + text = re.sub(r'[\w\-] [\w\-]', None, text) + + return self.parser.parse(text), text + """ + ), + _expected_function( + r""" + def parse(self, text: str) -> tuple[Tree[Token], str]: + text = re.sub(r'[\w\-] [\w\-]', dashrepl, None) + + return self.parser.parse(text), text + """ + ), + _expected_function( + """ + def parse(self, text: str) -> tuple[Tree[Token], str]: + text = re.sub(dashrepl, text) + + return self.parser.parse(text), text + """ + ), + _expected_function( + r""" + def parse(self, text: str) -> tuple[Tree[Token], str]: + text = re.sub(r'[\w\-] [\w\-]', text) + + return self.parser.parse(text), text + """ + ), + _expected_function( + r""" + def parse(self, text: str) -> tuple[Tree[Token], str]: + text = re.sub(r'[\w\-] [\w\-]', dashrepl, ) + + return self.parser.parse(text), text + """ + ), + _expected_function( + r""" + def parse(self, text: str) -> tuple[Tree[Token], str]: + text = re.sub(r'XX[\w\-] [\w\-]XX', dashrepl, text) + + return self.parser.parse(text), text + """ + ), + _expected_function( + r""" + def parse(self, text: str) -> tuple[Tree[Token], str]: + text = re.sub(r'[\w\-] [\w\-]', dashrepl, text) + + return self.parser.parse(None), text + """ + ), ] assert sorted(mutants) == sorted(expected) @@ -481,50 +654,70 @@ def foo(): def test_orig_function_name_from_key(): assert orig_function_and_class_names_from_key( - f'_{CLASS_NAME_SEPARATOR}Foo{CLASS_NAME_SEPARATOR}bar__mutmut_1') == ('bar', 'Foo') - assert orig_function_and_class_names_from_key('x_bar__mutmut_1') == ('bar', None) + f"_{CLASS_NAME_SEPARATOR}Foo{CLASS_NAME_SEPARATOR}bar__nootnoot_1" + ) == ("bar", "Foo") + assert orig_function_and_class_names_from_key("x_bar__nootnoot_1") == ("bar", None) def test_mangle_function_name(): - assert mangle_function_name(name='bar', class_name=None) == 'x_bar' - assert mangle_function_name(name='bar', class_name='Foo') == f'x{CLASS_NAME_SEPARATOR}Foo{CLASS_NAME_SEPARATOR}bar' + assert mangle_function_name(name="bar", class_name=None) == "x_bar" + assert ( + mangle_function_name(name="bar", class_name="Foo") + == f"x{CLASS_NAME_SEPARATOR}Foo{CLASS_NAME_SEPARATOR}bar" + ) -def test_diff_ops(): +def test_diff_ops(nootnoot_state): source = """ -def foo(): +def foo(): return 1 class Foo: - def member(self): + def member(self): return 3 """.strip() - mutants_source, mutant_names = mutate_file_contents('filename', source) + mutants_source, mutant_names = mutate_file_contents("filename", source) assert len(mutant_names) == 2 - diff1 = get_diff_for_mutant(mutant_name=mutant_names[0], source=mutants_source, path='test.py').strip() - diff2 = get_diff_for_mutant(mutant_name=mutant_names[1], source=mutants_source, path='test.py').strip() - - assert diff1 == ''' + diff1 = get_diff_for_mutant( + nootnoot_state, + mutant_name=mutant_names[0], + source=mutants_source, + path="test.py", + ).strip() + diff2 = get_diff_for_mutant( + nootnoot_state, + mutant_name=mutant_names[1], + source=mutants_source, + path="test.py", + ).strip() + + assert ( + diff1 + == """ --- test.py +++ test.py @@ -1,2 +1,2 @@ - def foo(): + def foo(): - return 1 + return 2 -'''.strip() +""".strip() + ) - assert diff2 == ''' + assert ( + diff2 + == """ --- test.py +++ test.py @@ -1,2 +1,2 @@ - def member(self): + def member(self): - return 3 + return 4 -'''.strip() +""".strip() + ) def test_from_future_still_first(): @@ -536,8 +729,8 @@ def foo(): return 1 """.strip() mutated_source = mutated_module(source) - assert mutated_source.split('\n')[0] == 'from __future__ import annotations' - assert mutated_source.count('from __future__') == 1 + assert mutated_source.split("\n")[0] == "from __future__ import annotations" + assert mutated_source.count("from __future__") == 1 def test_from_future_with_docstring_still_first(): @@ -550,67 +743,56 @@ def foo(): return 1 """.strip() mutated_source = mutated_module(source) - assert mutated_source.split('\n')[0] == "'''This documents the module'''" - assert mutated_source.split('\n')[1] == 'from __future__ import annotations' - assert mutated_source.count('from __future__') == 1 + assert mutated_source.split("\n")[0] == "'''This documents the module'''" + assert mutated_source.split("\n")[1] == "from __future__ import annotations" + assert mutated_source.count("from __future__") == 1 # Negate the effects of CatchOutput because it does not play nicely with capfd in GitHub Actions -@patch.object(CatchOutput, 'dump_output') -@patch.object(CatchOutput, 'stop') -@patch.object(CatchOutput, 'start') -def test_run_forced_fail_test_with_failing_test(_start, _stop, _dump_output, capfd): - mutmut._reset_globals() +@pytest.mark.usefixtures("mock_catch_output") +def test_run_forced_fail_test_with_failing_test(nootnoot_state, capfd): runner = _mocked_runner_run_forced_failed(return_value=1) - - run_forced_fail_test(runner) + prev = os.environ.get("MUTANT_UNDER_TEST") + run_forced_fail_test(runner, nootnoot_state) out, err = capfd.readouterr() print() print(f"out: {out}") print(f"err: {err}") - assert 'done' in out - assert not os.environ['MUTANT_UNDER_TEST'] + assert "done" in err + assert os.environ.get("MUTANT_UNDER_TEST") == prev # Negate the effects of CatchOutput because it does not play nicely with capfd in GitHub Actions -@patch.object(CatchOutput, 'dump_output') -@patch.object(CatchOutput, 'stop') -@patch.object(CatchOutput, 'start') -def test_run_forced_fail_test_with_mutmut_programmatic_fail_exception(_start, _stop, _dump_output, capfd): - mutmut._reset_globals() - runner = _mocked_runner_run_forced_failed(side_effect=MutmutProgrammaticFailException()) +@pytest.mark.usefixtures("mock_catch_output") +def test_run_forced_fail_test_with_nootnoot_programmatic_fail_exception(nootnoot_state, capfd): + runner = _mocked_runner_run_forced_failed(side_effect=NootNootProgrammaticFailException()) - run_forced_fail_test(runner) + prev = os.environ.get("MUTANT_UNDER_TEST") + run_forced_fail_test(runner, nootnoot_state) - out, _ = capfd.readouterr() - assert 'done' in out - assert not os.environ['MUTANT_UNDER_TEST'] + _out, err = capfd.readouterr() + assert "done" in err + assert os.environ.get("MUTANT_UNDER_TEST") == prev # Negate the effects of CatchOutput because it does not play nicely with capfd in GitHub Actions -@patch.object(CatchOutput, 'dump_output') -@patch.object(CatchOutput, 'stop') -@patch.object(CatchOutput, 'start') -def test_run_forced_fail_test_with_all_tests_passing(_start, _stop, _dump_output, capfd): - mutmut._reset_globals() +@pytest.mark.usefixtures("mock_catch_output") +def test_run_forced_fail_test_with_all_tests_passing(nootnoot_state, capfd): runner = _mocked_runner_run_forced_failed(return_value=0) with pytest.raises(SystemExit) as error: - run_forced_fail_test(runner) + run_forced_fail_test(runner, nootnoot_state) assert error.value.code == 1 - out, _ = capfd.readouterr() - assert 'FAILED: Unable to force test failures' in out + _out, err = capfd.readouterr() + assert "FAILED: Unable to force test failures" in err def _mocked_runner_run_forced_failed(return_value=None, side_effect=None): runner = Mock() - runner.run_forced_fail = Mock( - return_value=return_value, - side_effect=side_effect - ) + runner.run_forced_fail = Mock(return_value=return_value, side_effect=side_effect) return runner @@ -638,7 +820,7 @@ def x(self): # TODO: implement removal of inner decorators -@pytest.mark.skip +@pytest.mark.xfail(reason="not implemented", strict=True) def test_decorated_inner_functions_mutation(): source = """ def foo(): @@ -647,7 +829,7 @@ def inner(): pass""".strip() expected = """ -def x_foo__mutmut_1(): +def x_foo__nootnoot_1(): def inner(): pass""".strip() @@ -679,78 +861,5 @@ def add(self, value): src, _ = mutate_file_contents("file.py", source) - assert src == f"""from __future__ import division -import lib - -lib.foo() -{trampoline_impl.strip()} - -def x_foo__mutmut_orig(a, b): - return a > b - -def x_foo__mutmut_1(a, b): - return a >= b - -x_foo__mutmut_mutants : ClassVar[MutantDict] = {{ -'x_foo__mutmut_1': x_foo__mutmut_1 -}} - -def foo(*args, **kwargs): - result = _mutmut_trampoline(x_foo__mutmut_orig, x_foo__mutmut_mutants, args, kwargs) - return result - -foo.__signature__ = _mutmut_signature(x_foo__mutmut_orig) -x_foo__mutmut_orig.__name__ = 'x_foo' - -def x_bar__mutmut_orig(): - yield 1 - -def x_bar__mutmut_1(): - yield 2 - -x_bar__mutmut_mutants : ClassVar[MutantDict] = {{ -'x_bar__mutmut_1': x_bar__mutmut_1 -}} - -def bar(*args, **kwargs): - result = _mutmut_trampoline(x_bar__mutmut_orig, x_bar__mutmut_mutants, args, kwargs) - return result - -bar.__signature__ = _mutmut_signature(x_bar__mutmut_orig) -x_bar__mutmut_orig.__name__ = 'x_bar' - -class Adder: - def xǁAdderǁ__init____mutmut_orig(self, amount): - self.amount = amount - def xǁAdderǁ__init____mutmut_1(self, amount): - self.amount = None - - xǁAdderǁ__init____mutmut_mutants : ClassVar[MutantDict] = {{ - 'xǁAdderǁ__init____mutmut_1': xǁAdderǁ__init____mutmut_1 - }} - - def __init__(self, *args, **kwargs): - result = _mutmut_trampoline(object.__getattribute__(self, "xǁAdderǁ__init____mutmut_orig"), object.__getattribute__(self, "xǁAdderǁ__init____mutmut_mutants"), args, kwargs, self) - return result - - __init__.__signature__ = _mutmut_signature(xǁAdderǁ__init____mutmut_orig) - xǁAdderǁ__init____mutmut_orig.__name__ = 'xǁAdderǁ__init__' - - def xǁAdderǁadd__mutmut_orig(self, value): - return self.amount + value - - def xǁAdderǁadd__mutmut_1(self, value): - return self.amount - value - - xǁAdderǁadd__mutmut_mutants : ClassVar[MutantDict] = {{ - 'xǁAdderǁadd__mutmut_1': xǁAdderǁadd__mutmut_1 - }} - - def add(self, *args, **kwargs): - result = _mutmut_trampoline(object.__getattribute__(self, "xǁAdderǁadd__mutmut_orig"), object.__getattribute__(self, "xǁAdderǁadd__mutmut_mutants"), args, kwargs, self) - return result - - add.__signature__ = _mutmut_signature(xǁAdderǁadd__mutmut_orig) - xǁAdderǁadd__mutmut_orig.__name__ = 'xǁAdderǁadd' - -print(Adder(1).add(2))""" + expected = Path("tests/data/module_mutation_expected.py.txt").read_text(encoding="utf-8") + assert src == expected diff --git a/tests/test_mutmut3.py b/tests/test_mutmut3.py deleted file mode 100644 index 2882b7e1..00000000 --- a/tests/test_mutmut3.py +++ /dev/null @@ -1,75 +0,0 @@ -from mutmut.file_mutation import mutate_file_contents -from mutmut.trampoline_templates import trampoline_impl - - -def mutated_module(source: str) -> str: - mutated_code, _ = mutate_file_contents('', source) - return mutated_code - - -def test_mutate_file_contents(): - source = """ -a + 1 - -def foo(a, b, c): - return a + b * c -""" - trampolines = trampoline_impl.removesuffix('\n\n') - - expected = f""" -a + 1{trampolines} - -def x_foo__mutmut_orig(a, b, c): - return a + b * c - -def x_foo__mutmut_1(a, b, c): - return a - b * c - -def x_foo__mutmut_2(a, b, c): - return a + b / c - -x_foo__mutmut_mutants : ClassVar[MutantDict] = {{ -'x_foo__mutmut_1': x_foo__mutmut_1, - 'x_foo__mutmut_2': x_foo__mutmut_2 -}} - -def foo(*args, **kwargs): - result = _mutmut_trampoline(x_foo__mutmut_orig, x_foo__mutmut_mutants, args, kwargs) - return result - -foo.__signature__ = _mutmut_signature(x_foo__mutmut_orig) -x_foo__mutmut_orig.__name__ = 'x_foo' -""" - - result = mutated_module(source) - - assert result == expected - - -def test_avoid_annotations(): - source = """ -def foo(a: List[int]) -> int: - return 1 -""" - - expected = trampoline_impl.removesuffix('\n\n') + """ -def x_foo__mutmut_orig(a: List[int]) -> int: - return 1 -def x_foo__mutmut_1(a: List[int]) -> int: - return 2 - -x_foo__mutmut_mutants : ClassVar[MutantDict] = { -'x_foo__mutmut_1': x_foo__mutmut_1 -} - -def foo(*args, **kwargs): - result = _mutmut_trampoline(x_foo__mutmut_orig, x_foo__mutmut_mutants, args, kwargs) - return result - -foo.__signature__ = _mutmut_signature(x_foo__mutmut_orig) -x_foo__mutmut_orig.__name__ = 'x_foo' -""" - - result = mutated_module(source) - - assert result == expected diff --git a/tests/test_nootnoot3.py b/tests/test_nootnoot3.py new file mode 100644 index 00000000..f356b027 --- /dev/null +++ b/tests/test_nootnoot3.py @@ -0,0 +1,78 @@ +from nootnoot.core.file_mutation import mutate_file_contents +from nootnoot.core.trampoline_templates import trampoline_impl + + +def mutated_module(source: str) -> str: + mutated_code, _ = mutate_file_contents("", source) + return mutated_code + + +def test_mutate_file_contents(): + source = """ +a + 1 + +def foo(a, b, c): + return a + b * c +""" + trampolines = trampoline_impl.removesuffix("\n\n") + + expected = f""" +a + 1{trampolines} + +def x_foo__nootnoot_orig(a, b, c): + return a + b * c + +def x_foo__nootnoot_1(a, b, c): + return a - b * c + +def x_foo__nootnoot_2(a, b, c): + return a + b / c + +x_foo__nootnoot_mutants : ClassVar[MutantDict] = {{ +'x_foo__nootnoot_1': x_foo__nootnoot_1, + 'x_foo__nootnoot_2': x_foo__nootnoot_2 +}} + +def foo(*args, **kwargs): + result = _nootnoot_trampoline(x_foo__nootnoot_orig, x_foo__nootnoot_mutants, args, kwargs) + return result + +foo.__signature__ = _nootnoot_signature(x_foo__nootnoot_orig) +x_foo__nootnoot_orig.__name__ = 'x_foo' +""" + + result = mutated_module(source) + + assert result == expected + + +def test_avoid_annotations(): + source = """ +def foo(a: List[int]) -> int: + return 1 +""" + + expected = ( + trampoline_impl.removesuffix("\n\n") + + """ +def x_foo__nootnoot_orig(a: List[int]) -> int: + return 1 +def x_foo__nootnoot_1(a: List[int]) -> int: + return 2 + +x_foo__nootnoot_mutants : ClassVar[MutantDict] = { +'x_foo__nootnoot_1': x_foo__nootnoot_1 +} + +def foo(*args, **kwargs): + result = _nootnoot_trampoline(x_foo__nootnoot_orig, x_foo__nootnoot_mutants, args, kwargs) + return result + +foo.__signature__ = _nootnoot_signature(x_foo__nootnoot_orig) +x_foo__nootnoot_orig.__name__ = 'x_foo' +""" + ) + + result = mutated_module(source) + + assert result == expected diff --git a/tests/test_persistence.py b/tests/test_persistence.py new file mode 100644 index 00000000..96abd690 --- /dev/null +++ b/tests/test_persistence.py @@ -0,0 +1,114 @@ +import json +import warnings +from pathlib import Path + +from nootnoot.app.config import Config +from nootnoot.app.meta import SourceFileMutationData +from nootnoot.app.persistence import SCHEMA_VERSION, load_stats, save_stats +from nootnoot.app.state import NootNootState + + +def make_state(*, debug: bool) -> NootNootState: + state = NootNootState() + state.config = Config( + also_copy=[], + do_not_mutate=[], + max_stack_depth=-1, + debug=debug, + paths_to_mutate=[Path("src")], + pytest_add_cli_args=[], + pytest_add_cli_args_test_selection=[], + tests_dir=[], + mutate_only_covered_lines=False, + ) + return state + + +def test_stats_roundtrip_with_schema_version(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + Path("mutants").mkdir() + state = make_state(debug=False) + state.duration_by_test["tests/test_a.py::test_a"] = 1.25 + state.tests_by_mangled_function_name["pkg.mod.x__nootnoot_1"] = {"tests/test_a.py::test_a"} + state.stats_time = 9.5 + + save_stats(state) + + raw = json.loads(Path("mutants/nootnoot-stats.json").read_text(encoding="utf-8")) + assert raw["schema_version"] == SCHEMA_VERSION + + reloaded = make_state(debug=False) + assert load_stats(reloaded) is True + assert reloaded.duration_by_test["tests/test_a.py::test_a"] == 1.25 + assert reloaded.tests_by_mangled_function_name["pkg.mod.x__nootnoot_1"] == {"tests/test_a.py::test_a"} + assert reloaded.stats_time == 9.5 + + +def test_stats_unknown_keys_warns_in_debug(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + Path("mutants").mkdir() + + payload = { + "schema_version": SCHEMA_VERSION, + "tests_by_mangled_function_name": {"pkg.mod.x__nootnoot_1": ["t"]}, + "duration_by_test": {"t": 0.1}, + "stats_time": 0.0, + "extra": "value", + } + Path("mutants/nootnoot-stats.json").write_text( + json.dumps(payload, indent=4), + encoding="utf-8", + ) + + state = make_state(debug=True) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + assert load_stats(state) is True + + assert any("unexpected keys" in str(item.message) for item in caught) + + +def test_meta_roundtrip_with_schema_version(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + Path("mutants/src").mkdir(parents=True) + source_path = Path("src/example.py") + + source_data = SourceFileMutationData(path=source_path) + source_data.exit_code_by_key = {"pkg.mod.x__nootnoot_1": 0} + source_data.durations_by_key = {"pkg.mod.x__nootnoot_1": 0.4} + source_data.estimated_time_of_tests_by_mutant = {"pkg.mod.x__nootnoot_1": 1.2} + source_data.save() + + raw = json.loads(Path("mutants/src/example.py.meta").read_text(encoding="utf-8")) + assert raw["schema_version"] == SCHEMA_VERSION + + reloaded = SourceFileMutationData(path=source_path) + reloaded.load() + assert reloaded.exit_code_by_key == source_data.exit_code_by_key + assert reloaded.durations_by_key == source_data.durations_by_key + assert reloaded.estimated_time_of_tests_by_mutant == source_data.estimated_time_of_tests_by_mutant + + +def test_meta_unknown_keys_warns_in_debug(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + Path("mutants/src").mkdir(parents=True) + source_path = Path("src/example.py") + + payload = { + "schema_version": SCHEMA_VERSION, + "exit_code_by_key": {"pkg.mod.x__nootnoot_1": 0}, + "durations_by_key": {"pkg.mod.x__nootnoot_1": 0.4}, + "estimated_durations_by_key": {"pkg.mod.x__nootnoot_1": 1.2}, + "extra": "value", + } + Path("mutants/src/example.py.meta").write_text( + json.dumps(payload, indent=4), + encoding="utf-8", + ) + + source_data = SourceFileMutationData(path=source_path) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + source_data.load(debug=True) + + assert any("unexpected keys" in str(item.message) for item in caught) diff --git a/tests/test_reporting.py b/tests/test_reporting.py new file mode 100644 index 00000000..bef16fc1 --- /dev/null +++ b/tests/test_reporting.py @@ -0,0 +1,48 @@ +import json + +from nootnoot.app.events import RunEvent +from nootnoot.app.reporting import REPORT_SCHEMA_VERSION, RunReport, render_json_report + + +def test_render_json_report_schema() -> None: + report = RunReport( + summary={ + "killed": 1, + "survived": 0, + "timeout": 0, + "no_tests": 0, + "suspicious": 0, + "skipped": 0, + "not_checked": 0, + "total": 1, + "check_was_interrupted_by_user": 0, + "segfault": 0, + }, + mutants=[ + { + "name": "pkg.mod.func__nootnoot_1", + "path": "src/pkg/mod.py", + "exit_code": 1, + "status": "killed", + "duration_seconds": 0.2, + "estimated_duration_seconds": 0.1, + } + ], + events=[ + RunEvent( + event="session_started", + data={"max_children": 2, "mutant_names": []}, + ), + RunEvent( + event="session_finished", + data={"summary": {"killed": 1}, "duration_seconds": 1.0}, + ), + ], + ) + + payload = json.loads(render_json_report(report)) + + assert payload["schema_version"] == REPORT_SCHEMA_VERSION + assert payload["summary"]["killed"] == 1 + assert payload["mutants"][0]["status"] == "killed" + assert payload["events"][0]["event"] == "session_started" diff --git a/uv.lock b/uv.lock index 7c1ecbac..e4e039b6 100644 --- a/uv.lock +++ b/uv.lock @@ -1,21 +1,133 @@ version = 1 revision = 3 -requires-python = ">=3.10" +requires-python = ">=3.12" resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", "python_full_version < '3.13'", ] +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "boolean-py" +version = "5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" }, +] + +[[package]] +name = "cachecontrol" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" }, +] + +[package.optional-dependencies] +filecache = [ + { name = "filelock" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "chardet" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + [[package]] name = "click" -version = "8.0.0" +version = "8.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/99/286fd2fdfb501620a9341319ba47444040c7b3094d3b6c797d7281469bf8/click-8.0.0.tar.gz", hash = "sha256:7d8c289ee437bcb0316820ccee14aefcb056e58d31830ecab8e47eda6540e136", size = 326171, upload-time = "2021-05-11T20:46:02.218Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/11/597f9102867dc0972b698972f05f50925f586639e57beba4db352029e8f9/click-8.0.0-py3-none-any.whl", hash = "sha256:e90e62ced43dc8105fb9a26d62f0d9340b5c8db053a814e25d95c19873ae87db", size = 96881, upload-time = "2021-05-11T20:45:59.718Z" }, + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[package]] @@ -27,55 +139,289 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "colorlog" +version = "6.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162, upload-time = "2025-10-16T16:14:11.978Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/c1/e419ef3723a074172b68aaa89c9f3de486ed4c2399e2dbd8113a4fdcaf9e/colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c", size = 11743, upload-time = "2025-10-16T16:14:10.512Z" }, +] + +[[package]] +name = "configparser" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/ac/ea19242153b5e8be412a726a70e82c7b5c1537c83f61b20995b2eda3dcd7/configparser-7.2.0.tar.gz", hash = "sha256:b629cc8ae916e3afbd36d1b3d093f34193d851e11998920fdcfc4552218b7b70", size = 51273, upload-time = "2025-03-08T16:04:09.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/fe/f61e7129e9e689d9e40bbf8a36fb90f04eceb477f4617c02c6a18463e81f/configparser-7.2.0-py3-none-any.whl", hash = "sha256:fee5e1f3db4156dcd0ed95bc4edfa3580475537711f67a819c966b389d09ce62", size = 17232, upload-time = "2025-03-08T16:04:07.743Z" }, +] + [[package]] name = "coverage" -version = "7.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/87/c0163d39ac70cab62ebcaee164c988215cd312919a78940c2251a2fcfabb/coverage-7.3.0.tar.gz", hash = "sha256:49dbb19cdcafc130f597d9e04a29d0a032ceedf729e41b181f51cd170e6ee865", size = 763902, upload-time = "2023-08-12T18:35:26.346Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/58/9c4bb389ccc0ba9f9337d7e2f313a96dacbd2647e774cdc43de4325186d4/coverage-7.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:db76a1bcb51f02b2007adacbed4c88b6dee75342c37b05d1822815eed19edee5", size = 201031, upload-time = "2023-08-12T18:34:06.77Z" }, - { url = "https://files.pythonhosted.org/packages/9e/3b/5bdcbd8c64abf4eb1d61addf11754ad5883f3bda1d612cc843cbb3958902/coverage-7.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c02cfa6c36144ab334d556989406837336c1d05215a9bdf44c0bc1d1ac1cb637", size = 201327, upload-time = "2023-08-12T18:34:09.232Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e5/724283de5799ce58e5efd5f1989919f115d9db273baa98befd99827c80cf/coverage-7.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:477c9430ad5d1b80b07f3c12f7120eef40bfbf849e9e7859e53b9c93b922d2af", size = 229827, upload-time = "2023-08-12T18:34:10.914Z" }, - { url = "https://files.pythonhosted.org/packages/a5/40/2e6791d3bca7734d6c2f42527aa9656630215a8324b459ae21bb98905251/coverage-7.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce2ee86ca75f9f96072295c5ebb4ef2a43cecf2870b0ca5e7a1cbdd929cf67e1", size = 228139, upload-time = "2023-08-12T18:34:12.342Z" }, - { url = "https://files.pythonhosted.org/packages/f7/02/f24041262825e15425fc77ecc63555cf3741d3eec3ed06acdd4bdd636a9b/coverage-7.3.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68d8a0426b49c053013e631c0cdc09b952d857efa8f68121746b339912d27a12", size = 229012, upload-time = "2023-08-12T18:34:13.99Z" }, - { url = "https://files.pythonhosted.org/packages/2f/60/6fb960383f9159f67ba08924de6f8ac75aac6107c67dc9c6a533e0fccd3e/coverage-7.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b3eb0c93e2ea6445b2173da48cb548364f8f65bf68f3d090404080d338e3a689", size = 234929, upload-time = "2023-08-12T18:34:15.629Z" }, - { url = "https://files.pythonhosted.org/packages/09/7e/2f686c461ca6f28d32b248ec369c387798ec7e28a4525b2f79988c3f8164/coverage-7.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:90b6e2f0f66750c5a1178ffa9370dec6c508a8ca5265c42fbad3ccac210a7977", size = 233170, upload-time = "2023-08-12T18:34:17.612Z" }, - { url = "https://files.pythonhosted.org/packages/8c/1f/a8132477bd5ca4f7e372c7d01bf8e844db6c0805f18d3d0e0b913e6cc22e/coverage-7.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:96d7d761aea65b291a98c84e1250cd57b5b51726821a6f2f8df65db89363be51", size = 234389, upload-time = "2023-08-12T18:34:19.378Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b0/e15a3acf0dce6215afcd2186f53fd534d2b456208e078409431b9e70445a/coverage-7.3.0-cp310-cp310-win32.whl", hash = "sha256:63c5b8ecbc3b3d5eb3a9d873dec60afc0cd5ff9d9f1c75981d8c31cfe4df8527", size = 203483, upload-time = "2023-08-12T18:34:21.034Z" }, - { url = "https://files.pythonhosted.org/packages/4d/bd/1c3e5ccc7372fa7b65b294017444ef7b3040016349a3762c561ad271375a/coverage-7.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:97c44f4ee13bce914272589b6b41165bbb650e48fdb7bd5493a38bde8de730a1", size = 204398, upload-time = "2023-08-12T18:34:22.398Z" }, - { url = "https://files.pythonhosted.org/packages/a6/c5/c94da7b5ee14a0e7b046b2d59b50fe37d50ae78046e3459639961d3dccf5/coverage-7.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:74c160285f2dfe0acf0f72d425f3e970b21b6de04157fc65adc9fd07ee44177f", size = 201209, upload-time = "2023-08-12T18:34:23.608Z" }, - { url = "https://files.pythonhosted.org/packages/56/61/0bc551ef5e4cd459c34e769969b080d667ea9b2b3265819d4ae1f8d07702/coverage-7.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b543302a3707245d454fc49b8ecd2c2d5982b50eb63f3535244fd79a4be0c99d", size = 201423, upload-time = "2023-08-12T18:34:24.842Z" }, - { url = "https://files.pythonhosted.org/packages/7a/6b/f16c757f34adaf76413b061ff412d599958a299dba5dfb9371e5567b77d9/coverage-7.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad0f87826c4ebd3ef484502e79b39614e9c03a5d1510cfb623f4a4a051edc6fd", size = 233474, upload-time = "2023-08-12T18:34:26.095Z" }, - { url = "https://files.pythonhosted.org/packages/62/b9/de6fc3a608b4c0438b96e120fe83304d39b6be640b14363004843602118d/coverage-7.3.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13c6cbbd5f31211d8fdb477f0f7b03438591bdd077054076eec362cf2207b4a7", size = 231048, upload-time = "2023-08-12T18:34:27.931Z" }, - { url = "https://files.pythonhosted.org/packages/55/63/f2dcc8f7f1587ae54bf8cc1c3b08e07e442633a953537dfaf658a0cbac2c/coverage-7.3.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fac440c43e9b479d1241fe9d768645e7ccec3fb65dc3a5f6e90675e75c3f3e3a", size = 232856, upload-time = "2023-08-12T18:34:29.68Z" }, - { url = "https://files.pythonhosted.org/packages/44/39/809e546b31d871e9636315d0097891ae3177e0f6da2021c489f64dbe00b7/coverage-7.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3c9834d5e3df9d2aba0275c9f67989c590e05732439b3318fa37a725dff51e74", size = 241805, upload-time = "2023-08-12T18:34:30.841Z" }, - { url = "https://files.pythonhosted.org/packages/2a/b2/f2b519d33ececf73cf3d616fc7d051a73aa9609859fde376e902d79b69ce/coverage-7.3.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4c8e31cf29b60859876474034a83f59a14381af50cbe8a9dbaadbf70adc4b214", size = 240219, upload-time = "2023-08-12T18:34:32.116Z" }, - { url = "https://files.pythonhosted.org/packages/c5/ad/1559ab85952a47531004f9a32bcac51f9755e9541fb03eae42a9358e00dd/coverage-7.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7a9baf8e230f9621f8e1d00c580394a0aa328fdac0df2b3f8384387c44083c0f", size = 241271, upload-time = "2023-08-12T18:34:33.895Z" }, - { url = "https://files.pythonhosted.org/packages/32/5a/d8e474e01fde6511bf8354df005248aeb2e3a71dacfe1624fbc2916a15f4/coverage-7.3.0-cp311-cp311-win32.whl", hash = "sha256:ccc51713b5581e12f93ccb9c5e39e8b5d4b16776d584c0f5e9e4e63381356482", size = 203467, upload-time = "2023-08-12T18:34:35.196Z" }, - { url = "https://files.pythonhosted.org/packages/b1/cb/48d62b864e408bea2608b4ce19ba1feba0ffbf5a03640cf024cb3122e895/coverage-7.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:887665f00ea4e488501ba755a0e3c2cfd6278e846ada3185f42d391ef95e7e70", size = 204490, upload-time = "2023-08-12T18:34:36.509Z" }, - { url = "https://files.pythonhosted.org/packages/dd/53/2de98835e2976d042fd30967e6b00d57e688cfcc17ad10f11dc2c307ec9c/coverage-7.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d000a739f9feed900381605a12a61f7aaced6beae832719ae0d15058a1e81c1b", size = 201331, upload-time = "2023-08-12T18:34:37.829Z" }, - { url = "https://files.pythonhosted.org/packages/9b/01/49a4f47d87acc3be6cd0013c33b7ef6e1acc13f67ac9ff2fd1f7d73b4b12/coverage-7.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:59777652e245bb1e300e620ce2bef0d341945842e4eb888c23a7f1d9e143c446", size = 201429, upload-time = "2023-08-12T18:34:39.032Z" }, - { url = "https://files.pythonhosted.org/packages/05/1d/45d448cfa9cdf7aea9ec49711a143c82afc793e9542f9ba9e3f5b83c4d4d/coverage-7.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9737bc49a9255d78da085fa04f628a310c2332b187cd49b958b0e494c125071", size = 234294, upload-time = "2023-08-12T18:34:40.415Z" }, - { url = "https://files.pythonhosted.org/packages/a9/43/29bb5ceabd87bdff07ac29333a68828f210e7c2e928c85464e9264f7a8df/coverage-7.3.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5247bab12f84a1d608213b96b8af0cbb30d090d705b6663ad794c2f2a5e5b9fe", size = 231652, upload-time = "2023-08-12T18:34:42.185Z" }, - { url = "https://files.pythonhosted.org/packages/82/a6/194198e62702d82ee581a035fcc5032a7bebc0264eb5ebffb466c6b5b4ea/coverage-7.3.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2ac9a1de294773b9fa77447ab7e529cf4fe3910f6a0832816e5f3d538cfea9a", size = 233627, upload-time = "2023-08-12T18:34:43.604Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5b/4e7ec6cc17a0cb4afc1aa99e6877d5e2c6377cdfeac67dba39643e1d4809/coverage-7.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:85b7335c22455ec12444cec0d600533a238d6439d8d709d545158c1208483873", size = 240463, upload-time = "2023-08-12T18:34:45.095Z" }, - { url = "https://files.pythonhosted.org/packages/d1/6b/b7f5e6e7ae64f0b8795dfb499ba73a5bae66131b518c1e5c448fb838d3c9/coverage-7.3.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:36ce5d43a072a036f287029a55b5c6a0e9bd73db58961a273b6dc11a2c6eb9c2", size = 238427, upload-time = "2023-08-12T18:34:46.303Z" }, - { url = "https://files.pythonhosted.org/packages/01/40/a0f76d77a9a64947fc3dac90b0f62fbd7f4d02e62d10a7126f6785eb2cbe/coverage-7.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:211a4576e984f96d9fce61766ffaed0115d5dab1419e4f63d6992b480c2bd60b", size = 240139, upload-time = "2023-08-12T18:34:48.115Z" }, - { url = "https://files.pythonhosted.org/packages/e3/b9/6244d38d1574bd13995025802dbc5577acd5aab143e53ddecc087d485a30/coverage-7.3.0-cp312-cp312-win32.whl", hash = "sha256:56afbf41fa4a7b27f6635bc4289050ac3ab7951b8a821bca46f5b024500e6321", size = 203768, upload-time = "2023-08-12T18:34:50.16Z" }, - { url = "https://files.pythonhosted.org/packages/17/11/48d4804db0f3b0277a857b57ade93f03cb9f2afbce0e07c208a9f9b01805/coverage-7.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f297e0c1ae55300ff688568b04ff26b01c13dfbf4c9d2b7d0cb688ac60df479", size = 204653, upload-time = "2023-08-12T18:34:51.461Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d5/1bf0476b77b1466970a0d7a9982806efa3e5ab5c63f94db623c7458b97b7/coverage-7.3.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:5492a6ce3bdb15c6ad66cb68a0244854d9917478877a25671d70378bdc8562d0", size = 193508, upload-time = "2023-08-12T18:35:23.999Z" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.0" +version = "7.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, + { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, + { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, + { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, + { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, + { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, + { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +] + +[[package]] +name = "cyclonedx-python-lib" +version = "11.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "license-expression" }, + { name = "packageurl-python" }, + { name = "py-serializable" }, + { name = "sortedcontainers" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +sdist = { url = "https://files.pythonhosted.org/packages/89/ed/54ecfa25fc145c58bf4f98090f7b6ffe5188d0759248c57dde44427ea239/cyclonedx_python_lib-11.6.0.tar.gz", hash = "sha256:7fb85a4371fa3a203e5be577ac22b7e9a7157f8b0058b7448731474d6dea7bf0", size = 1408147, upload-time = "2025-12-02T12:28:46.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/1b/534ad8a5e0f9470522811a8e5a9bc5d328fb7738ba29faf357467a4ef6d0/cyclonedx_python_lib-11.6.0-py3-none-any.whl", hash = "sha256:94f4aae97db42a452134dafdddcfab9745324198201c4777ed131e64c8380759", size = 511157, upload-time = "2025-12-02T12:28:44.158Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "diff-cover" +version = "10.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "chardet" }, + { name = "jinja2" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/d2/764a9889d14c30600dea9ddb745ab64af2ca41dcd3cfd272e45654352ccb/diff_cover-10.1.0.tar.gz", hash = "sha256:fd21db41eebe9a6facac5c8f49e4bb164d010211b40694c05b151aa99331dd5e", size = 101730, upload-time = "2025-12-31T03:36:49.493Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/78/90b2c95133ea4a9b0f85be653b1000d84fa1c623570973f3113ff822d600/diff_cover-10.1.0-py3-none-any.whl", hash = "sha256:0943508c7f969d5ed8edd7b29652005814736acac52cfc50d3a5448165418e1b", size = 56584, upload-time = "2025-12-31T03:36:48.004Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/23/ce7a1126827cedeb958fc043d61745754464eb56c5937c35bbf2b8e26f34/filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c", size = 19476, upload-time = "2025-12-15T23:54:28.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/7f/a1a97644e39e7316d850784c642093c99df1290a460df4ede27659056834/filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a", size = 16666, upload-time = "2025-12-15T23:54:26.874Z" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/c8/dd58967d119baab745caec2f9d853297cec1989ec1d63f677d3880632b88/gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c", size = 215076, upload-time = "2025-07-24T03:45:54.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" }, +] + +[[package]] +name = "grimp" +version = "3.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/46/79764cfb61a3ac80dadae5d94fb10acdb7800e31fecf4113cf3d345e4952/grimp-3.14.tar.gz", hash = "sha256:645fbd835983901042dae4e1b24fde3a89bf7ac152f9272dd17a97e55cb4f871", size = 830882, upload-time = "2025-12-10T17:55:01.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/d6/a35ff62f35aa5fd148053506eddd7a8f2f6afaed31870dc608dd0eb38e4f/grimp-3.14-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ffabc6940301214753bad89ec0bfe275892fa1f64b999e9a101f6cebfc777133", size = 2178573, upload-time = "2025-12-10T17:53:42.836Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/bd2e80273da4d46110969fc62252e5372e0249feb872bc7fe76fdc7f1818/grimp-3.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:075d9a1c78d607792d0ed8d4d3d7754a621ef04c8a95eaebf634930dc9232bb2", size = 2110452, upload-time = "2025-12-10T17:53:19.831Z" }, + { url = "https://files.pythonhosted.org/packages/44/c3/7307249c657d34dca9d250d73ba027d6cfe15a98fb3119b6e5210bc388b7/grimp-3.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06ff52addeb20955a4d6aa097bee910573ffc9ef0d3c8a860844f267ad958156", size = 2283064, upload-time = "2025-12-10T17:52:07.673Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d2/cae4cf32dc8d4188837cc4ab183300d655f898969b0f169e240f3b7c25be/grimp-3.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d10e0663e961fcbe8d0f54608854af31f911f164c96a44112d5173050132701f", size = 2235893, upload-time = "2025-12-10T17:52:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/04/92/3f58bc3064fc305dac107d08003ba65713a5bc89a6d327f1c06b30cce752/grimp-3.14-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ab874d7ddddc7a1291259cf7c31a4e7b5c612e9da2e24c67c0eb1a44a624e67", size = 2393376, upload-time = "2025-12-10T17:53:02.397Z" }, + { url = "https://files.pythonhosted.org/packages/06/b8/f476f30edf114f04cb58e8ae162cb4daf52bda0ab01919f3b5b7edb98430/grimp-3.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54fec672ec83355636a852177f5a470c964bede0f6730f9ba3c7b5c8419c9eab", size = 2571342, upload-time = "2025-12-10T17:52:35.214Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ae/2e44d3c4f591f95f86322a8f4dbb5aac17001d49e079f3a80e07e7caaf09/grimp-3.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b9e221b5e8070a916c780e88c877fee2a61c95a76a76a2a076396e459511b0bb", size = 2359022, upload-time = "2025-12-10T17:52:49.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/ac/42b4d6bc0ea119ce2e91e1788feabf32c5433e9617dbb495c2a3d0dc7f12/grimp-3.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eea6b495f9b4a8d82f5ce544921e76d0d12017f5d1ac3a3bd2f5ac88ab055b1c", size = 2309424, upload-time = "2025-12-10T17:53:11.069Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c7/6a731989625c1790f4da7602dcbf9d6525512264e853cda77b3b3602d5e0/grimp-3.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:655e8d3f79cd99bb859e09c9dd633515150e9d850879ca71417d5ac31809b745", size = 2462754, upload-time = "2025-12-10T17:53:50.886Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4d/3d1571c0a39a59dd68be4835f766da64fe64cbab0d69426210b716a8bdf0/grimp-3.14-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a14f10b1b71c6c37647a76e6a49c226509648107abc0f48c1e3ecd158ba05531", size = 2501356, upload-time = "2025-12-10T17:54:06.014Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d1/8950b8229095ebda5c54c8784e4d1f0a6e19423f2847289ef9751f878798/grimp-3.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:81685111ee24d3e25f8ed9e77ed00b92b58b2414e1a1c2937236026900972744", size = 2504631, upload-time = "2025-12-10T17:54:34.441Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/23bed3da9206138d36d01890b656c7fb7adfb3a37daac8842d84d8777ade/grimp-3.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ce8352a8ea0e27b143136ea086582fc6653419aa8a7c15e28ed08c898c42b185", size = 2514751, upload-time = "2025-12-10T17:54:49.384Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/6f1f55c97ee982f133ec5ccb22fc99bf5335aee70c208f4fb86cd833b8d5/grimp-3.14-cp312-cp312-win32.whl", hash = "sha256:3fc0f98b3c60d88e9ffa08faff3200f36604930972f8b29155f323b76ea25a06", size = 1875041, upload-time = "2025-12-10T17:55:13.326Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cf/03ba01288e2a41a948bc8526f32c2eeaddd683ed34be1b895e31658d5a4c/grimp-3.14-cp312-cp312-win_amd64.whl", hash = "sha256:6bca77d1d50c8dc402c96af21f4e28e2f1e9938eeabd7417592a22bd83cde3c3", size = 2013868, upload-time = "2025-12-10T17:55:05.907Z" }, + { url = "https://files.pythonhosted.org/packages/3b/bd/d12a9c821b79ba31fc52243e564712b64140fc6d011c2bdbb483d9092a12/grimp-3.14-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af8a625554beea84530b98cc471902155b5fc042b42dc47ec846fa3e32b0c615", size = 2178632, upload-time = "2025-12-10T17:53:44.55Z" }, + { url = "https://files.pythonhosted.org/packages/96/8c/d6620dbc245149d5a5a7a9342733556ba91a672f358259c0ab31d889b56b/grimp-3.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0dd1942ffb419ad342f76b0c3d3d2d7f312b264ddc578179d13ce8d5acec1167", size = 2110288, upload-time = "2025-12-10T17:53:21.662Z" }, + { url = "https://files.pythonhosted.org/packages/60/9d/ea51edc4eb295c99786040051c66466bfa235fd1def9f592057b36e03d0f/grimp-3.14-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:537f784ce9b4acf8657f0b9714ab69a6c72ffa752eccc38a5a85506103b1a194", size = 2282197, upload-time = "2025-12-10T17:52:09.304Z" }, + { url = "https://files.pythonhosted.org/packages/28/6e/7db27818ced6a797f976ca55d981a3af5c12aec6aeda12d63965847cd028/grimp-3.14-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:78ab18c08770aa005bef67b873bc3946d33f65727e9f3e508155093db5fa57d6", size = 2235720, upload-time = "2025-12-10T17:52:21.806Z" }, + { url = "https://files.pythonhosted.org/packages/37/26/0e3bbae4826bd6eaabf404738400414071e73ddb1e65bf487dcce17858c4/grimp-3.14-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28ca58728c27e7292c99f964e6ece9295c2f9cfdefc37c18dea0679c783ffb6f", size = 2393023, upload-time = "2025-12-10T17:53:04.149Z" }, + { url = "https://files.pythonhosted.org/packages/49/f2/7da91db5703da34c7ef4c7cddcbb1a8fc30cd85fe54756eba942c6fb27d8/grimp-3.14-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9b5577de29c6c5ae6e08d4ca0ac361b45dba323aa145796e6b320a6ea35414b7", size = 2571108, upload-time = "2025-12-10T17:52:36.523Z" }, + { url = "https://files.pythonhosted.org/packages/25/5e/4d6278f18032c7208696edf8be24a4b5f7fad80acc20ffca737344bcecb5/grimp-3.14-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d7d1f9f42306f455abcec34db877e4887ff15f2777a43491f7ccbd6936c449b", size = 2358531, upload-time = "2025-12-10T17:52:50.521Z" }, + { url = "https://files.pythonhosted.org/packages/24/fb/231c32493161ac82f27af6a56965daefa0ec6030fdaf5b948ddd5d68d000/grimp-3.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39bd5c9b7cef59ee30a05535e9cb4cbf45a3c503f22edce34d0aa79362a311a9", size = 2308831, upload-time = "2025-12-10T17:53:12.587Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/f6db325bf5efbbebc9c85cad0af865e821a12a0ba58ee309e938cbd5fedf/grimp-3.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7fec3116b4f780a1bc54176b19e6b9f2e36e2ef3164b8fc840660566af35df88", size = 2462138, upload-time = "2025-12-10T17:53:52.403Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/cc3fe29cf07f70364018086840c228a190539ab8105147e34588db590792/grimp-3.14-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0233a35a5bbb23688d63e1736b54415fa9994ace8dfeb7de8514ed9dee212968", size = 2501393, upload-time = "2025-12-10T17:54:22.486Z" }, + { url = "https://files.pythonhosted.org/packages/e5/eb/54cada9a726455148da23f64577b5cd164164d23a6449e3fa14551157356/grimp-3.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e46b2fef0f1da7e7e2f8129eb93c7e79db716ff7810140a22ce5504e10ed86df", size = 2504514, upload-time = "2025-12-10T17:54:36.34Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c7/e6afe4f0652df07e8762f61899d1202b73c22c559c804d0a09e5aab2ff17/grimp-3.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e6d9b50623ee1c3d2a1927ec3f5d408995ea1f92f3e91ed996c908bb40e856f", size = 2514018, upload-time = "2025-12-10T17:54:50.76Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/2b8550acc1f010301f02c4fe9664810929fd9277cd032ab608b8534a96fb/grimp-3.14-cp313-cp313-win32.whl", hash = "sha256:fd57c56f5833c99320ec77e8ba5508d56f6fb48ec8032a942f7931cc6ebb80ce", size = 1874922, upload-time = "2025-12-10T17:55:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/46/c7/bc9db5a54ef22972cd17d15ad80a8fee274a471bd3f02300405702d29ea5/grimp-3.14-cp313-cp313-win_amd64.whl", hash = "sha256:173307cf881a126fe5120b7bbec7d54384002e3c83dcd8c4df6ce7f0fee07c53", size = 2013705, upload-time = "2025-12-10T17:55:07.488Z" }, + { url = "https://files.pythonhosted.org/packages/80/7e/02710bf5e50997168c84ac622b10dd41d35515efd0c67549945ad20996a0/grimp-3.14-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebe29f8f13fbd7c314908ed535183a36e6db71839355b04869b27f23c58fa082", size = 2281868, upload-time = "2025-12-10T17:52:10.589Z" }, + { url = "https://files.pythonhosted.org/packages/15/88/2e440c6762cc78bd50582e1b092357d2255f0852ccc6218d8db25170ab31/grimp-3.14-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:073d285b00100153fd86064c7726bb1b6d610df1356d33bb42d3fd8809cb6e72", size = 2230917, upload-time = "2025-12-10T17:52:23.212Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bb/2e7dce129b88f07fc525fe5c97f28cfb7ed7b62c59386d39226b4d08969c/grimp-3.14-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f6d6efc37e1728bbfcd881b89467be5f7b046292597b3ebe5f8e44e89ea8b6cb", size = 2571371, upload-time = "2025-12-10T17:52:37.84Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2b/8f1be8294af60c953687db7dec25525d87ed9c2aa26b66dcbe5244abaca2/grimp-3.14-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5337d65d81960b712574c41e85b480d4480bbb5c6f547c94e634f6c60d730889", size = 2356980, upload-time = "2025-12-10T17:52:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/35/ca/ead91e04b3ddd4774ae74601860ea0f0f21bcf6b970b6769ba9571eb2904/grimp-3.14-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:84a7fea63e352b325daa89b0b7297db411b7f0036f8d710c32f8e5090e1fc3ca", size = 2461540, upload-time = "2025-12-10T17:53:53.749Z" }, + { url = "https://files.pythonhosted.org/packages/94/aa/f8a085ff73c37d6e6a37de9f58799a3fea9e16badf267aaef6f11c9a53a3/grimp-3.14-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:d0b19a3726377165fe1f7184a8af317734d80d32b371b6c5578747867ab53c0b", size = 2497925, upload-time = "2025-12-10T17:54:23.842Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a3/db3c2d6df07fe74faf5a28fcf3b44fad2831d323ba4a3c2ff66b77a6520c/grimp-3.14-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9caa4991f530750f88474a3f5ecf6ef9f0d064034889d92db00cfb4ecb78aa24", size = 2501794, upload-time = "2025-12-10T17:54:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/095f4e3765e7b60425a41e9fbd2b167f8b0acb957cc88c387f631778a09d/grimp-3.14-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1876efc119b99332a5cc2b08a6bdaada2f0ad94b596f0372a497e2aa8bda4d94", size = 2515203, upload-time = "2025-12-10T17:54:52.555Z" }, + { url = "https://files.pythonhosted.org/packages/c6/5f/ee02a3a1237282d324f596a50923bf9d2cb1b1230ef2fef49fb4d3563c2c/grimp-3.14-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3ccf03e65864d6bc7bf1c003c319f5330a7627b3677f31143f11691a088464c2", size = 2177150, upload-time = "2025-12-10T17:53:46.145Z" }, + { url = "https://files.pythonhosted.org/packages/f2/64/2a92889e5fc78e8ef5c548e6a5c6fed78b817eeb0253aca586c28108393a/grimp-3.14-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9ecd58fa58a270e7523f8bec9e6452f4fdb9c21e4cd370640829f1e43fa87a69", size = 2109280, upload-time = "2025-12-10T17:53:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/69/02/5d0b9ab54821e7fbdeb02f3919fa2cb8b9f0c3869fa6e4b969a5766f0ffa/grimp-3.14-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d75d1f8f7944978b39b08d870315174f1ffcd5123be6ccff8ce90467ace648a", size = 2283367, upload-time = "2025-12-10T17:52:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/c2/96/a77c40c92faf7500f42ac019ab8de108b04ffe3db8ec8d6f90416d2322ce/grimp-3.14-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6f70bbb1dd6055d08d29e39a78a11c4118c1778b39d17cd8271e18e213524ca7", size = 2237125, upload-time = "2025-12-10T17:52:24.606Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5e/3e1483721c83057bff921cf454dd5ff3e661ae1d2e63150a380382d116c2/grimp-3.14-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f21b7c003626c902669dc26ede83a91220cf0a81b51b27128370998c2f247b4", size = 2391735, upload-time = "2025-12-10T17:53:05.619Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cb/25fad4a174fe672d42f3e5616761a8120a3b03c8e9e2ae3f31159561968a/grimp-3.14-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80d9f056415c936b45561310296374c4319b5df0003da802c84d2830a103792a", size = 2571388, upload-time = "2025-12-10T17:52:39.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/7e/456df7f6a765ce3f160eb32a0f64ed0c1c3cd39b518555dde02087f9b6e4/grimp-3.14-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0332963cd63a45863775d4237e59dedf95455e0a1ea50c356be23100c5fc1d7c", size = 2359637, upload-time = "2025-12-10T17:52:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/7c/98/3e5005ef21a4e2243f0da489aba86aaaff0bc11d5240d67113482cba88e0/grimp-3.14-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f4144350d074f2058fe7c89230a26b34296b161f085b0471a692cb2fe27036f", size = 2308335, upload-time = "2025-12-10T17:53:13.893Z" }, + { url = "https://files.pythonhosted.org/packages/8a/03/4e055f756946d6f71ab7e9d1f8536a9e476777093dd7a050f40412d1a2b1/grimp-3.14-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e148e67975e92f90a8435b1b4c02180b9a3f3d725b7a188ba63793f1b1e445a0", size = 2463680, upload-time = "2025-12-10T17:53:55.507Z" }, + { url = "https://files.pythonhosted.org/packages/26/b9/3c76b7c2e1587e4303a6eff6587c2117c3a7efe1b100cd13d8a4a5613572/grimp-3.14-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1093f7770cb5f3ca6f99fb152f9c949381cc0b078dfdfe598c8ab99abaccda3b", size = 2502808, upload-time = "2025-12-10T17:54:25.383Z" }, + { url = "https://files.pythonhosted.org/packages/20/80/ada10b85ad3125ebedea10256d9c568b6bf28339d2f79d2d196a7b94f633/grimp-3.14-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a213f45ec69e9c2b28ffd3ba5ab12cc9859da17083ba4dc39317f2083b618111", size = 2504013, upload-time = "2025-12-10T17:54:39.762Z" }, + { url = "https://files.pythonhosted.org/packages/05/45/7c369f749d50b0ceac23cd6874ca4695cc1359a96091c7010301e5c8b619/grimp-3.14-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f003ac3f226d2437a49af0b6036f26edba57f8a32d329275dbde1b2b2a00a56", size = 2515043, upload-time = "2025-12-10T17:54:54.437Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/85135fe83826ce11ae56a340d32a1391b91eed94d25ce7bc318019f735de/grimp-3.14-cp314-cp314-win32.whl", hash = "sha256:eec81be65a18f4b2af014b1e97296cc9ee20d1115529bf70dd7e06f457eac30b", size = 1877509, upload-time = "2025-12-10T17:55:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/db/61/e4a2234edecb3bb3cff8963bc4ec5cc482a9e3c54f8df0946d7d90003830/grimp-3.14-cp314-cp314-win_amd64.whl", hash = "sha256:cd3bab6164f1d5e313678f0ab4bf45955afe7f5bdb0f2f481014aa9cca7e81ba", size = 2014364, upload-time = "2025-12-10T17:55:08.896Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/3d304443fbf1df4d60c09668846d0c8a605c6c95646226e41d8f5c3254da/grimp-3.14-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b1df33de479be4d620f69633d1876858a8e64a79c07907d47cf3aaf896af057", size = 2281385, upload-time = "2025-12-10T17:52:13.668Z" }, + { url = "https://files.pythonhosted.org/packages/fe/13/493e2648dbb83b3fc517ee675e464beb0154551d726053c7982a3138c6a8/grimp-3.14-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07096d4402e9d5a2c59c402ea3d601f4b7f99025f5e32f077468846fc8d3821b", size = 2231470, upload-time = "2025-12-10T17:52:26.104Z" }, + { url = "https://files.pythonhosted.org/packages/80/84/e772b302385a6b7ec752c88f84ffe35c33d14076245ae27a635aed9c63a2/grimp-3.14-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:712bc28f46b354316af50c469c77953ba3d6cb4166a62b8fb086436a8b05d301", size = 2571579, upload-time = "2025-12-10T17:52:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/69/92/5b23aa7b89c5f4f2cfa636cbeaf33e784378a6b0a823d77a3448670dfacc/grimp-3.14-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abe2bbef1cf8e27df636c02f60184319f138dee4f3a949405c21a4b491980397", size = 2356545, upload-time = "2025-12-10T17:52:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/bcf2116f4b1c3939ab35f9cdddd9ca59e953e57e9a0ac0c143deaf9f29cc/grimp-3.14-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2f9ae3fabb7a7a8468ddc96acc84ecabd84f168e7ca508ee94d8f32ea9bd5de2", size = 2461022, upload-time = "2025-12-10T17:53:56.923Z" }, + { url = "https://files.pythonhosted.org/packages/81/ce/1a076dce6bc22bca4b9ad5d1bbcd7e1023dcf7bf20ea9404c6462d78f049/grimp-3.14-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:efaf11ea73f7f12d847c54a5d6edcbe919e0369dce2d1aabae6c50792e16f816", size = 2498256, upload-time = "2025-12-10T17:54:27.214Z" }, + { url = "https://files.pythonhosted.org/packages/45/ea/ac735bed202c1c5c019e611b92d3861779e0cfbe2d20fdb0dec94266d248/grimp-3.14-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e089c9ab8aa755ff5af88c55891727783b4eb6b228e7bdf278e17209d954aa1e", size = 2502056, upload-time = "2025-12-10T17:54:41.537Z" }, + { url = "https://files.pythonhosted.org/packages/80/8f/774ce522de6a7e70fbeceeaeb6fbe502f5dfb8365728fb3bb4cb23463da8/grimp-3.14-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a424ad14d5deb56721ac24ab939747f72ab3d378d42e7d1f038317d33b052b77", size = 2515157, upload-time = "2025-12-10T17:54:55.874Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.148.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/b3/e098d91195f121602bb3e4d00276cf1da0035df53e9deeb18115467d6da9/hypothesis-6.148.8.tar.gz", hash = "sha256:fa6b2ae029bc02f9d2d6c2257b0cbf2dc3782362457d2027a038ad7f4209c385", size = 471333, upload-time = "2025-12-23T01:46:25.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/95/0742f59910074262e98d9f3bb0f7fb7a6b4bfb7e70b6d203eeb5625a6452/hypothesis-6.148.8-py3-none-any.whl", hash = "sha256:c1842f47f974d74661b3779a26032f8b91bc1eb30d84741714d3712d7f43e85e", size = 538280, upload-time = "2025-12-23T01:46:22.555Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "import-linter" +version = "2.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "grimp" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/ea/9d3ba8e6851d22a073d21ff143a6b23f844dc97f46b41c0dccd26e26d6d3/import_linter-2.9.tar.gz", hash = "sha256:0d7da2a9bb0a534171a592795bd46c8cca86bd6dc6e6e665fa95ba4ed5024215", size = 288196, upload-time = "2025-12-11T11:55:06.087Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/62/3d/657a586f9324ad24538cd797d5c471286e217987e1d0f265575cebe594a9/import_linter-2.9-py3-none-any.whl", hash = "sha256:06403ede04c975cda2ea9050498c16b2021c0261b5cedf47c6c5d8725894b1a2", size = 44899, upload-time = "2025-12-11T11:55:04.87Z" }, ] [[package]] @@ -87,72 +433,107 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "libcst" -version = "1.8.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyyaml", marker = "python_full_version < '3.13'" }, - { name = "pyyaml-ft", marker = "python_full_version >= '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5c/55/ca4552d7fe79a91b2a7b4fa39991e8a45a17c8bfbcaf264597d95903c777/libcst-1.8.5.tar.gz", hash = "sha256:e72e1816eed63f530668e93a4c22ff1cf8b91ddce0ec53e597d3f6c53e103ec7", size = 884582, upload-time = "2025-09-26T05:29:44.101Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/61/92115569ba7d5ccf0bd74d33641d261d184a09a9ed58699a8463c44b79d5/libcst-1.8.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:373011a1a995c6201cf76c72ab598cedc27de9a5d665428620610f599bfc5f20", size = 2206397, upload-time = "2025-09-26T05:27:47.397Z" }, - { url = "https://files.pythonhosted.org/packages/fc/2f/199d716d211b4938ba9a6cd0406b9c4fe36432a0e843230d6fab18d3ef67/libcst-1.8.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:774df1b40d338d245bb2d4e368ed99feb72a4642984125a5db62a3f4013a6e87", size = 2090397, upload-time = "2025-09-26T05:27:49.578Z" }, - { url = "https://files.pythonhosted.org/packages/f5/38/0e058e52bd6ac3436f45315a943b8a214b856f3fe0b1fcdb5ca65c3d7e66/libcst-1.8.5-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:08762c19eaf3d72162150ac0f0e1aa70378a10182ee539b8ecdf55c7f83b7f82", size = 2231629, upload-time = "2025-09-26T05:27:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7e/d458480534bd30f4f9800228e38b96d647fc5f59d10f92ae2ab4d1271e5b/libcst-1.8.5-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:54a50034c29d477fd3ceed2bcc02e17142b354e4039831246c32fde59281d116", size = 2294203, upload-time = "2025-09-26T05:27:53.039Z" }, - { url = "https://files.pythonhosted.org/packages/54/ea/1900b3896b4a1bccdbee2484490cf50a00045f759e721e5e9915e5b4a4cf/libcst-1.8.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:667ec0b245b8fa1e4afaa69ab4640ff124d4f5e7a480196fedde705db69b8c56", size = 2297115, upload-time = "2025-09-26T05:27:55.027Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f8/ab4b1e7d0be5273948bd1e8f4ae1c3072dc4bf69f5fda559e04b30408e1b/libcst-1.8.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3b7e5142768418094fb8f673e107f01cfdfa70b72d6c97749f3619e2e8beacb1", size = 2398228, upload-time = "2025-09-26T05:27:56.654Z" }, - { url = "https://files.pythonhosted.org/packages/da/fb/2f334f94c6f61f1a8116da5234b2977efc2b752e23c7a6df0dd712a4f248/libcst-1.8.5-cp310-cp310-win_amd64.whl", hash = "sha256:4ad060e43bd3ba54b4fefcc5f619fc2480fd5a7dbec6768b598bfe0eb46e3da9", size = 2106562, upload-time = "2025-09-26T05:27:58.439Z" }, - { url = "https://files.pythonhosted.org/packages/c1/16/f281fd995028ab2dec978b3468b43e3d8a465b7d804c1df0b3f7da03284e/libcst-1.8.5-cp310-cp310-win_arm64.whl", hash = "sha256:985303bbc3c748c8fb71f994b56cc2806385b423acd53f5dd1cc191b3c2df6d3", size = 1992204, upload-time = "2025-09-26T05:28:00.539Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a0/4efb5b33c184f72554409516c73c8900909f87de528538d194b2cb5898ac/libcst-1.8.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:dd5a292ce2b6410bc100aeac2b18ba3554fd8a8f6aa0ee6a9238bb4031c521ca", size = 2206056, upload-time = "2025-09-26T05:28:02.503Z" }, - { url = "https://files.pythonhosted.org/packages/26/b0/8b1dca00aebfc89f8e538212e5582548cedfc0b8f3aa4e73a815fe87bdfd/libcst-1.8.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4f43915cd523a6967ba1dfe137627ed3804892005330c3bf53674a2ab4ff3dad", size = 2090132, upload-time = "2025-09-26T05:28:04.511Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1f/78ad030ca973f2c58fa58c3f30d94c2239473d3aba6c9dd1bdedd5047ddd/libcst-1.8.5-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9a756bd314b87b87dec9f0f900672c37719645b1c8bb2b53fe37b5b5fe7ee2c2", size = 2231559, upload-time = "2025-09-26T05:28:06.492Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/2ee78c01070c919de3d6736a06d1d9ecaedcbe1f367f4eee3c34ae5f801e/libcst-1.8.5-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:26e9d5e756447873eeda78441fa7d1fe640c0b526e5be2b6b7ee0c8f03c4665f", size = 2293973, upload-time = "2025-09-26T05:28:08.456Z" }, - { url = "https://files.pythonhosted.org/packages/50/cf/ef4cb1c1b16f4bd32b0d7a5f01b18168fd833010a916bc062958dd6bcd8a/libcst-1.8.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b33ec61f62ff6122dc9c5bf1401bc8a9f9a2f0663ca15661d21d14d9dc4de0", size = 2297099, upload-time = "2025-09-26T05:28:10.4Z" }, - { url = "https://files.pythonhosted.org/packages/75/3e/ccd2e449f09c745ded6925804a6fe66f4c96ef82a0330de646becb8c6140/libcst-1.8.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a80e14836ecbdf5374c2c82cd5cd290abaa7290ecfafe9259d0615a1ebccb30c", size = 2398032, upload-time = "2025-09-26T05:28:12.124Z" }, - { url = "https://files.pythonhosted.org/packages/1f/16/277d0666e77d53d0061cb73327053b114f516ab7b36c9d4c71963fb5e806/libcst-1.8.5-cp311-cp311-win_amd64.whl", hash = "sha256:588acde1588544b3bfe06069c118ee731e6712f323f26a026733f0ec4512596e", size = 2106472, upload-time = "2025-09-26T05:28:13.945Z" }, - { url = "https://files.pythonhosted.org/packages/bd/25/b1594abbec644a10b61ee1c1bab935ccc992a17b3880aa50234b9b4e9b06/libcst-1.8.5-cp311-cp311-win_arm64.whl", hash = "sha256:a8146f945f1eb46406fab676f86de3b7f88aca9e5d421f6366f7a63c8a950254", size = 1991976, upload-time = "2025-09-26T05:28:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/13/bb/c7abe0654fcf00292d6959256948ce4ae07785c4f65a45c3e25cc4637074/libcst-1.8.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27c7733aba7b43239157661207b1e3a9f3711a7fc061a0eca6a33f0716fdfd21", size = 2196690, upload-time = "2025-09-26T05:28:17.839Z" }, - { url = "https://files.pythonhosted.org/packages/49/25/e7c02209e8ce66e7b75a66d132118f6f812a8b03cd31ee7d96de56c733a1/libcst-1.8.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b8c3cfbbf6049e3c587713652e4b3c88cfbf7df7878b2eeefaa8dd20a48dc607", size = 2082616, upload-time = "2025-09-26T05:28:19.794Z" }, - { url = "https://files.pythonhosted.org/packages/32/68/a4f49d99e3130256e225d639722440ba2682c12812a30ebd7ba64fd0fd31/libcst-1.8.5-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:31d86025d8997c853f85c4b5d494f04a157fb962e24f187b4af70c7755c9b27d", size = 2229037, upload-time = "2025-09-26T05:28:21.459Z" }, - { url = "https://files.pythonhosted.org/packages/b2/62/4fa21600a0bf3eb9f4d4f8bbb50ef120fb0b2990195eabba997b0b889566/libcst-1.8.5-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ff9c535cfe99f0be79ac3024772b288570751fc69fc472b44fca12d1912d1561", size = 2292806, upload-time = "2025-09-26T05:28:23.033Z" }, - { url = "https://files.pythonhosted.org/packages/14/df/a01e8d54b62060698e37e3e28f77559ecb70c7b93ffee00d17e40221f419/libcst-1.8.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e8204607504563d3606bbaea2b9b04e0cef2b3bdc14c89171a702c1e09b9318a", size = 2294836, upload-time = "2025-09-26T05:28:24.937Z" }, - { url = "https://files.pythonhosted.org/packages/75/4f/c410e7f7ceda0558f688c1ca5dfb3a40ff8dfc527f8e6015fa749e11a650/libcst-1.8.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5e6cd3df72d47701b205fa3349ba8899566df82cef248c2fdf5f575d640419c4", size = 2396004, upload-time = "2025-09-26T05:28:26.582Z" }, - { url = "https://files.pythonhosted.org/packages/f0/07/bb77dcb94badad0ad3e5a1e992a4318dbdf40632eac3b5cf18299858ad7d/libcst-1.8.5-cp312-cp312-win_amd64.whl", hash = "sha256:197c2f86dd0ca5c6464184ddef7f6440d64c8da39b78d16fc053da6701ed1209", size = 2107301, upload-time = "2025-09-26T05:28:28.235Z" }, - { url = "https://files.pythonhosted.org/packages/79/70/e688e6d99d6920c3f97bf8bbaec33ac2c71a947730772a1d32dd899dbbf1/libcst-1.8.5-cp312-cp312-win_arm64.whl", hash = "sha256:c5ca109c9a81dff3d947dceba635a08f9c3dfeb7f61b0b824a175ef0a98ea69b", size = 1990870, upload-time = "2025-09-26T05:28:29.858Z" }, - { url = "https://files.pythonhosted.org/packages/b0/77/ca1d2499881c774121ebb7c78c22f371c179f18317961e1e529dafc1af52/libcst-1.8.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9e9563dcd754b65557ba9cdff9a5af32cfa5f007be0db982429580db45bfe", size = 2196687, upload-time = "2025-09-26T05:28:31.769Z" }, - { url = "https://files.pythonhosted.org/packages/ef/1c/fdb7c226ad82fcf3b1bb19c24d8e895588a0c1fd2bc81e30792d041e15bc/libcst-1.8.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:61d56839d237e9bf3310e6479ffaf6659f298940f0e0d2460ce71ee67a5375df", size = 2082639, upload-time = "2025-09-26T05:28:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/af/1a/c6e89455483355971d13f6d71ad717624686b50558f7e2c12393c2c8e2f1/libcst-1.8.5-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:b084769dcda2036265fc426eec5894c658af8d4b0e0d0255ab6bb78c8c9d6eb4", size = 2229202, upload-time = "2025-09-26T05:28:35.276Z" }, - { url = "https://files.pythonhosted.org/packages/02/9c/3e4ce737a34c0ada15a35f51d0dbd8bf0ac0cef0c4560ddc0a8364e3f712/libcst-1.8.5-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c20384b8a4a7801b4416ef96173f1fbb7fafad7529edfdf151811ef70423118a", size = 2293220, upload-time = "2025-09-26T05:28:37.201Z" }, - { url = "https://files.pythonhosted.org/packages/1a/74/a68fcb3625b0c218c01aaefef9366f505654a1aa64af99cfe7ff7c97bf41/libcst-1.8.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:271b0b363972ff7d2b8116add13977e7c3b2668c7a424095851d548d222dab18", size = 2295146, upload-time = "2025-09-26T05:28:39.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/c3/f4b6edf204f919c6968eb2d111c338098aebbe3fb5d5d95aceacfcf65d9a/libcst-1.8.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ba728c7aee73b330f49f2df0f0b56b74c95302eeb78860f8d5ff0e0fc52c887", size = 2396597, upload-time = "2025-09-26T05:28:41.162Z" }, - { url = "https://files.pythonhosted.org/packages/d0/94/b5cbe122db8f60e7e05bd56743f91d176f3da9b2101f8234e25bb3c5e493/libcst-1.8.5-cp313-cp313-win_amd64.whl", hash = "sha256:0abf0e87570cd3b06a8cafbb5378a9d1cbf12e4583dc35e0fff2255100da55a1", size = 2107479, upload-time = "2025-09-26T05:28:43.094Z" }, - { url = "https://files.pythonhosted.org/packages/05/4d/5e47752c37b33ea6fd1fac76f62e2caa37a6f78d841338bb8fd3dcf51498/libcst-1.8.5-cp313-cp313-win_arm64.whl", hash = "sha256:757390c3cf0b45d7ae1d1d4070c839b082926e762e65eab144f37a63ad33b939", size = 1990992, upload-time = "2025-09-26T05:28:44.993Z" }, - { url = "https://files.pythonhosted.org/packages/88/df/d0eaaed2c402f945fd049b990c98242cb6eace640258e9f8d484206a9666/libcst-1.8.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f8934763389cd21ce3ed229b63b994b79dac8be7e84a9da144823f46bc1ffc5c", size = 2187746, upload-time = "2025-09-26T05:28:46.946Z" }, - { url = "https://files.pythonhosted.org/packages/19/05/ca62c80dc5f2cf26c2d5d1428612950c6f04df66f765ab0ca8b7d42b4ba1/libcst-1.8.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b873caf04862b6649a2a961fce847f7515ba882be02376a924732cf82c160861", size = 2072530, upload-time = "2025-09-26T05:28:48.451Z" }, - { url = "https://files.pythonhosted.org/packages/1a/38/34a5825bd87badaf8bc0725e5816d395f43ea2f8d1f3cb6982cccc70a1a2/libcst-1.8.5-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:50e095d18c4f76da0e03f25c50b52a2999acbcbe4598a3cf41842ee3c13b54f1", size = 2219819, upload-time = "2025-09-26T05:28:50.328Z" }, - { url = "https://files.pythonhosted.org/packages/74/ea/10407cc1c06231079f5ee6c5e2c2255a2c3f876a7a7f13af734f9bb6ee0e/libcst-1.8.5-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:3a3c967725cc3e8fa5c7251188d57d48eec8835f44c6b53f7523992bec595fa0", size = 2283011, upload-time = "2025-09-26T05:28:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/5b/fc/c4e4c03b4804ac78b8209e83a3c15e449aa68ddd0e602d5c2cc4b7e1b9ed/libcst-1.8.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eed454ab77f4b18100c41d8973b57069e503943ea4e5e5bbb660404976a0fe7a", size = 2283315, upload-time = "2025-09-26T05:28:53.33Z" }, - { url = "https://files.pythonhosted.org/packages/bb/39/75e07c2933b55815b71b1971e5388a24d1d1475631266251249eaed8af28/libcst-1.8.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:39130e59868b8fa49f6eeedd46f008d3456fc13ded57e1c85b211636eb6425f3", size = 2387279, upload-time = "2025-09-26T05:28:54.872Z" }, - { url = "https://files.pythonhosted.org/packages/04/44/0315fb0f2ee8913d209a5caf57932db8efb3f562dbcdc5fb157de92fb098/libcst-1.8.5-cp313-cp313t-win_amd64.whl", hash = "sha256:a7b1cc3abfdba5ce36907f94f07e079528d4be52c07dfffa26f0e68eb1d25d45", size = 2098827, upload-time = "2025-09-26T05:28:56.877Z" }, - { url = "https://files.pythonhosted.org/packages/45/c2/1335fe9feb7d75526df454a8f9db77615460c69691c27af0a57621ca9e47/libcst-1.8.5-cp313-cp313t-win_arm64.whl", hash = "sha256:20354c4217e87afea936e9ea90c57fe0b2c5651f41b3ee59f5df8a53ab417746", size = 1979853, upload-time = "2025-09-26T05:28:58.408Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4e/4d961f15e7cc3f9924c4865158cf23de3cb1d9727be5bc5ec1f6b2e0e991/libcst-1.8.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f350ff2867b3075ba97a022de694f2747c469c25099216cef47b58caaee96314", size = 2196843, upload-time = "2025-09-26T05:29:00.64Z" }, - { url = "https://files.pythonhosted.org/packages/47/b5/706b51025218b31346335c8aa1e316e91dbd82b9bd60483a23842a59033b/libcst-1.8.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b95db09d04d125619a63f191c9534853656c4c76c303b8b4c5f950c8e610fba", size = 2082306, upload-time = "2025-09-26T05:29:02.498Z" }, - { url = "https://files.pythonhosted.org/packages/eb/78/53816b76257d9d149f074ac0b913be1c94d54fb07b3a77f3e11333659d36/libcst-1.8.5-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:60e62e966b45b7dee6f0ec0fd7687704d29be18ae670c5bc6c9c61a12ccf589f", size = 2230603, upload-time = "2025-09-26T05:29:04.123Z" }, - { url = "https://files.pythonhosted.org/packages/a6/06/4497c456ad0ace0f60a38f0935d6e080600532bcddeaf545443d4d7c4db2/libcst-1.8.5-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:7cbb330a352dde570059c73af7b7bbfaa84ae121f54d2ce46c5530351f57419d", size = 2293110, upload-time = "2025-09-26T05:29:05.685Z" }, - { url = "https://files.pythonhosted.org/packages/14/fc/9ef8cc7c0a9cca722b6f176cc82b5925dbcdfcee6e17cd6d3056d45af38e/libcst-1.8.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:71b2b1ef2305cba051252342a1a4f8e94e6b8e95d7693a7c15a00ce8849ef722", size = 2296366, upload-time = "2025-09-26T05:29:07.451Z" }, - { url = "https://files.pythonhosted.org/packages/2d/7e/799dac0cd086cc5dab3837ead9c72dd4e29a79323795dc52b2ebb3aac9a0/libcst-1.8.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0f504d06dfba909d1ba6a4acf60bfe3f22275444d6e0d07e472a5da4a209b0be", size = 2397188, upload-time = "2025-09-26T05:29:09.084Z" }, - { url = "https://files.pythonhosted.org/packages/1b/5c/e4f32439818db04ea43b1d6de1d375dcdd5ff33b828864900c340f26436c/libcst-1.8.5-cp314-cp314-win_amd64.whl", hash = "sha256:c69d2b39e360dea5490ccb5dcf5957dcbb1067d27dc1f3f0787d4e287f7744e2", size = 2183599, upload-time = "2025-09-26T05:29:11.039Z" }, - { url = "https://files.pythonhosted.org/packages/e2/f9/a457c3da610aef4b5f5c00f1feb67192594b77fb9dddab8f654161c1ea6f/libcst-1.8.5-cp314-cp314-win_arm64.whl", hash = "sha256:63405cb548b2d7b78531535a7819231e633b13d3dee3eb672d58f0f3322892ca", size = 2071025, upload-time = "2025-09-26T05:29:12.546Z" }, - { url = "https://files.pythonhosted.org/packages/4a/b6/37abad6fc44df268cd8c2a903ddb2108bd8ac324ef000c2dfcb03d763a41/libcst-1.8.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8a5921105610f35921cc4db6fa5e68e941c6da20ce7f9f93b41b6c66b5481353", size = 2187762, upload-time = "2025-09-26T05:29:14.322Z" }, - { url = "https://files.pythonhosted.org/packages/b4/19/d1118c0b25612a3f50fb2c4b2010562fbf7e7df30ad821bab0aae9cf7e4f/libcst-1.8.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:abded10e8d92462fa982d19b064c6f24ed7ead81cf3c3b71011e9764cb12923d", size = 2072565, upload-time = "2025-09-26T05:29:16.37Z" }, - { url = "https://files.pythonhosted.org/packages/f7/c8/f72515e2774234c4f92909222d762789cc4be2247ed4189bc0639ade1f8c/libcst-1.8.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dd7bdb14545c4b77a6c0eb39c86a76441fe833da800f6ca63e917e1273621029", size = 2219884, upload-time = "2025-09-26T05:29:18.118Z" }, - { url = "https://files.pythonhosted.org/packages/f4/b8/b267b28cbb0cae19e8c7887cdeda72288ae1020d1c22b6c9955f065b296e/libcst-1.8.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6dc28d33ab8750a84c28b5625f7916846ecbecefd89bf75a5292a35644b6efbd", size = 2282790, upload-time = "2025-09-26T05:29:19.578Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/46f2b01bb6782dbc0f4e917ed029b1236278a5dc6d263e55ee986a83a88e/libcst-1.8.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:970b7164a71c65e13c961965f9677bbbbeb21ce2e7e6655294f7f774156391c4", size = 2283591, upload-time = "2025-09-26T05:29:21.024Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ca/3097729b5f6ab1d5e3a753492912d1d8b483a320421d3c0e9e26f1ecef0c/libcst-1.8.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd74c543770e6a61dcb8846c9689dfcce2ad686658896f77f3e21b6ce94bcb2e", size = 2386780, upload-time = "2025-09-26T05:29:22.922Z" }, - { url = "https://files.pythonhosted.org/packages/bb/cc/4fc91968779b70429106797ddb2265a18b0026e17ec6ba805c34427d2fb9/libcst-1.8.5-cp314-cp314t-win_amd64.whl", hash = "sha256:3d8e80cd1ed6577166f0bab77357f819f12564c2ed82307612e2bcc93e684d72", size = 2174807, upload-time = "2025-09-26T05:29:24.799Z" }, - { url = "https://files.pythonhosted.org/packages/79/3c/db47e1cf0c98a13cbea2cb5611e7b6913ac5e63845b0e41ee7020b03f523/libcst-1.8.5-cp314-cp314t-win_arm64.whl", hash = "sha256:a026aaa19cb2acd8a4d9e2a215598b0a7e2c194bf4482eb9dec4d781ec6e10b2", size = 2059048, upload-time = "2025-09-26T05:29:28.425Z" }, +version = "1.8.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml", marker = "python_full_version != '3.13.*'" }, + { name = "pyyaml-ft", marker = "python_full_version == '3.13.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/cd/337df968b38d94c5aabd3e1b10630f047a2b345f6e1d4456bd9fe7417537/libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b", size = 891354, upload-time = "2025-11-03T22:33:30.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/3c/93365c17da3d42b055a8edb0e1e99f1c60c776471db6c9b7f1ddf6a44b28/libcst-1.8.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c13d5bd3d8414a129e9dccaf0e5785108a4441e9b266e1e5e9d1f82d1b943c9", size = 2206166, upload-time = "2025-11-03T22:32:16.012Z" }, + { url = "https://files.pythonhosted.org/packages/1d/cb/7530940e6ac50c6dd6022349721074e19309eb6aa296e942ede2213c1a19/libcst-1.8.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1472eeafd67cdb22544e59cf3bfc25d23dc94058a68cf41f6654ff4fcb92e09", size = 2083726, upload-time = "2025-11-03T22:32:17.312Z" }, + { url = "https://files.pythonhosted.org/packages/1b/cf/7e5eaa8c8f2c54913160671575351d129170db757bb5e4b7faffed022271/libcst-1.8.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:089c58e75cb142ec33738a1a4ea7760a28b40c078ab2fd26b270dac7d2633a4d", size = 2235755, upload-time = "2025-11-03T22:32:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/55/54/570ec2b0e9a3de0af9922e3bb1b69a5429beefbc753a7ea770a27ad308bd/libcst-1.8.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c9d7aeafb1b07d25a964b148c0dda9451efb47bbbf67756e16eeae65004b0eb5", size = 2301473, upload-time = "2025-11-03T22:32:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/11/4c/163457d1717cd12181c421a4cca493454bcabd143fc7e53313bc6a4ad82a/libcst-1.8.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:207481197afd328aa91d02670c15b48d0256e676ce1ad4bafb6dc2b593cc58f1", size = 2298899, upload-time = "2025-11-03T22:32:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/35/1d/317ddef3669883619ef3d3395ea583305f353ef4ad87d7a5ac1c39be38e3/libcst-1.8.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:375965f34cc6f09f5f809244d3ff9bd4f6cb6699f571121cebce53622e7e0b86", size = 2408239, upload-time = "2025-11-03T22:32:23.275Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a1/f47d8cccf74e212dd6044b9d6dbc223636508da99acff1d54786653196bc/libcst-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:da95b38693b989eaa8d32e452e8261cfa77fe5babfef1d8d2ac25af8c4aa7e6d", size = 2119660, upload-time = "2025-11-03T22:32:24.822Z" }, + { url = "https://files.pythonhosted.org/packages/19/d0/dd313bf6a7942cdf951828f07ecc1a7695263f385065edc75ef3016a3cb5/libcst-1.8.6-cp312-cp312-win_arm64.whl", hash = "sha256:bff00e1c766658adbd09a175267f8b2f7616e5ee70ce45db3d7c4ce6d9f6bec7", size = 1999824, upload-time = "2025-11-03T22:32:26.131Z" }, + { url = "https://files.pythonhosted.org/packages/90/01/723cd467ec267e712480c772aacc5aa73f82370c9665162fd12c41b0065b/libcst-1.8.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7445479ebe7d1aff0ee094ab5a1c7718e1ad78d33e3241e1a1ec65dcdbc22ffb", size = 2206386, upload-time = "2025-11-03T22:32:27.422Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/b944944f910f24c094f9b083f76f61e3985af5a376f5342a21e01e2d1a81/libcst-1.8.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fc3fef8a2c983e7abf5d633e1884c5dd6fa0dcb8f6e32035abd3d3803a3a196", size = 2083945, upload-time = "2025-11-03T22:32:28.847Z" }, + { url = "https://files.pythonhosted.org/packages/36/a1/bd1b2b2b7f153d82301cdaddba787f4a9fc781816df6bdb295ca5f88b7cf/libcst-1.8.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1a3a5e4ee870907aa85a4076c914ae69066715a2741b821d9bf16f9579de1105", size = 2235818, upload-time = "2025-11-03T22:32:30.504Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ab/f5433988acc3b4d188c4bb154e57837df9488cc9ab551267cdeabd3bb5e7/libcst-1.8.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6609291c41f7ad0bac570bfca5af8fea1f4a27987d30a1fa8b67fe5e67e6c78d", size = 2301289, upload-time = "2025-11-03T22:32:31.812Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/89f4ba7a6f1ac274eec9903a9e9174890d2198266eee8c00bc27eb45ecf7/libcst-1.8.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25eaeae6567091443b5374b4c7d33a33636a2d58f5eda02135e96fc6c8807786", size = 2299230, upload-time = "2025-11-03T22:32:33.242Z" }, + { url = "https://files.pythonhosted.org/packages/f2/36/0aa693bc24cce163a942df49d36bf47a7ed614a0cd5598eee2623bc31913/libcst-1.8.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04030ea4d39d69a65873b1d4d877def1c3951a7ada1824242539e399b8763d30", size = 2408519, upload-time = "2025-11-03T22:32:34.678Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/6dd055b5f15afa640fb3304b2ee9df8b7f72e79513814dbd0a78638f4a0e/libcst-1.8.6-cp313-cp313-win_amd64.whl", hash = "sha256:8066f1b70f21a2961e96bedf48649f27dfd5ea68be5cd1bed3742b047f14acde", size = 2119853, upload-time = "2025-11-03T22:32:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ed/5ddb2a22f0b0abdd6dcffa40621ada1feaf252a15e5b2733a0a85dfd0429/libcst-1.8.6-cp313-cp313-win_arm64.whl", hash = "sha256:c188d06b583900e662cd791a3f962a8c96d3dfc9b36ea315be39e0a4c4792ebf", size = 1999808, upload-time = "2025-11-03T22:32:38.1Z" }, + { url = "https://files.pythonhosted.org/packages/25/d3/72b2de2c40b97e1ef4a1a1db4e5e52163fc7e7740ffef3846d30bc0096b5/libcst-1.8.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c41c76e034a1094afed7057023b1d8967f968782433f7299cd170eaa01ec033e", size = 2190553, upload-time = "2025-11-03T22:32:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/0d/20/983b7b210ccc3ad94a82db54230e92599c4a11b9cfc7ce3bc97c1d2df75c/libcst-1.8.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5432e785322aba3170352f6e72b32bea58d28abd141ac37cc9b0bf6b7c778f58", size = 2074717, upload-time = "2025-11-03T22:32:41.373Z" }, + { url = "https://files.pythonhosted.org/packages/13/f2/9e01678fedc772e09672ed99930de7355757035780d65d59266fcee212b8/libcst-1.8.6-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:85b7025795b796dea5284d290ff69de5089fc8e989b25d6f6f15b6800be7167f", size = 2225834, upload-time = "2025-11-03T22:32:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/4a/0d/7bed847b5c8c365e9f1953da274edc87577042bee5a5af21fba63276e756/libcst-1.8.6-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:536567441182a62fb706e7aa954aca034827b19746832205953b2c725d254a93", size = 2287107, upload-time = "2025-11-03T22:32:44.549Z" }, + { url = "https://files.pythonhosted.org/packages/02/f0/7e51fa84ade26c518bfbe7e2e4758b56d86a114c72d60309ac0d350426c4/libcst-1.8.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f04d3672bde1704f383a19e8f8331521abdbc1ed13abb349325a02ac56e5012", size = 2288672, upload-time = "2025-11-03T22:32:45.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cd/15762659a3f5799d36aab1bc2b7e732672722e249d7800e3c5f943b41250/libcst-1.8.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f04febcd70e1e67917be7de513c8d4749d2e09206798558d7fe632134426ea4", size = 2392661, upload-time = "2025-11-03T22:32:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6b/b7f9246c323910fcbe021241500f82e357521495dcfe419004dbb272c7cb/libcst-1.8.6-cp313-cp313t-win_amd64.whl", hash = "sha256:1dc3b897c8b0f7323412da3f4ad12b16b909150efc42238e19cbf19b561cc330", size = 2105068, upload-time = "2025-11-03T22:32:49.145Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0b/4fd40607bc4807ec2b93b054594373d7fa3d31bb983789901afcb9bcebe9/libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42", size = 1985181, upload-time = "2025-11-03T22:32:50.597Z" }, + { url = "https://files.pythonhosted.org/packages/3a/60/4105441989e321f7ad0fd28ffccb83eb6aac0b7cfb0366dab855dcccfbe5/libcst-1.8.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b188e626ce61de5ad1f95161b8557beb39253de4ec74fc9b1f25593324a0279c", size = 2204202, upload-time = "2025-11-03T22:32:52.311Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/51a6f285c3a183e50cfe5269d4a533c21625aac2c8de5cdf2d41f079320d/libcst-1.8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87e74f7d7dfcba9efa91127081e22331d7c42515f0a0ac6e81d4cf2c3ed14661", size = 2083581, upload-time = "2025-11-03T22:32:54.269Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/921b1c19b638860af76cdb28bc81d430056592910b9478eea49e31a7f47a/libcst-1.8.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a926a4b42015ee24ddfc8ae940c97bd99483d286b315b3ce82f3bafd9f53474", size = 2236495, upload-time = "2025-11-03T22:32:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/12/a8/b00592f9bede618cbb3df6ffe802fc65f1d1c03d48a10d353b108057d09c/libcst-1.8.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fbb7f569e69fd9e89d9d9caa57ca42c577c28ed05062f96a8c207594e75b8", size = 2301466, upload-time = "2025-11-03T22:32:57.337Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/790d9002f31580fefd0aec2f373a0f5da99070e04c5e8b1c995d0104f303/libcst-1.8.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08bd63a8ce674be431260649e70fca1d43f1554f1591eac657f403ff8ef82c7a", size = 2300264, upload-time = "2025-11-03T22:32:58.852Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/dc3f10e65bab461be5de57850d2910a02c24c3ddb0da28f0e6e4133c3487/libcst-1.8.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e00e275d4ba95d4963431ea3e409aa407566a74ee2bf309a402f84fc744abe47", size = 2408572, upload-time = "2025-11-03T22:33:00.552Z" }, + { url = "https://files.pythonhosted.org/packages/20/3b/35645157a7590891038b077db170d6dd04335cd2e82a63bdaa78c3297dfe/libcst-1.8.6-cp314-cp314-win_amd64.whl", hash = "sha256:fea5c7fa26556eedf277d4f72779c5ede45ac3018650721edd77fd37ccd4a2d4", size = 2193917, upload-time = "2025-11-03T22:33:02.354Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a2/1034a9ba7d3e82f2c2afaad84ba5180f601aed676d92b76325797ad60951/libcst-1.8.6-cp314-cp314-win_arm64.whl", hash = "sha256:bb9b4077bdf8857b2483879cbbf70f1073bc255b057ec5aac8a70d901bb838e9", size = 2078748, upload-time = "2025-11-03T22:33:03.707Z" }, + { url = "https://files.pythonhosted.org/packages/95/a1/30bc61e8719f721a5562f77695e6154e9092d1bdf467aa35d0806dcd6cea/libcst-1.8.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:55ec021a296960c92e5a33b8d93e8ad4182b0eab657021f45262510a58223de1", size = 2188980, upload-time = "2025-11-03T22:33:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/2c/14/c660204532407c5628e3b615015a902ed2d0b884b77714a6bdbe73350910/libcst-1.8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ba9ab2b012fbd53b36cafd8f4440a6b60e7e487cd8b87428e57336b7f38409a4", size = 2074828, upload-time = "2025-11-03T22:33:06.864Z" }, + { url = "https://files.pythonhosted.org/packages/82/e2/c497c354943dff644749f177ee9737b09ed811b8fc842b05709a40fe0d1b/libcst-1.8.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c0a0cc80aebd8aa15609dd4d330611cbc05e9b4216bcaeabba7189f99ef07c28", size = 2225568, upload-time = "2025-11-03T22:33:08.354Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/45999676d07bd6d0eefa28109b4f97124db114e92f9e108de42ba46a8028/libcst-1.8.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:42a4f68121e2e9c29f49c97f6154e8527cd31021809cc4a941c7270aa64f41aa", size = 2286523, upload-time = "2025-11-03T22:33:10.206Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6c/517d8bf57d9f811862f4125358caaf8cd3320a01291b3af08f7b50719db4/libcst-1.8.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a434c521fadaf9680788b50d5c21f4048fa85ed19d7d70bd40549fbaeeecab1", size = 2288044, upload-time = "2025-11-03T22:33:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/24d7d49478ffb61207f229239879845da40a374965874f5ee60f96b02ddb/libcst-1.8.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a65f844d813ab4ef351443badffa0ae358f98821561d19e18b3190f59e71996", size = 2392605, upload-time = "2025-11-03T22:33:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/39/c3/829092ead738b71e96a4e96896c96f276976e5a8a58b4473ed813d7c962b/libcst-1.8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb14bc4d4d83a57062fed2c5da93ecb426ff65b0dc02ddf3481040f5f074a82", size = 2181581, upload-time = "2025-11-03T22:33:14.514Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/5d6a790a02eb0d9d36c4aed4f41b277497e6178900b2fa29c35353aa45ed/libcst-1.8.6-cp314-cp314t-win_arm64.whl", hash = "sha256:819c8081e2948635cab60c603e1bbdceccdfe19104a242530ad38a36222cb88f", size = 2065000, upload-time = "2025-11-03T22:33:16.257Z" }, +] + +[[package]] +name = "license-expression" +version = "30.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boolean-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, ] [[package]] @@ -167,6 +548,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, ] +[[package]] +name = "mando" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/24/cd70d5ae6d35962be752feccb7dca80b5e0c2d450e995b16abd6275f3296/mando-0.7.1.tar.gz", hash = "sha256:18baa999b4b613faefb00eac4efadcf14f510b59b924b66e08289aa1de8c3500", size = 37868, upload-time = "2022-02-24T08:12:27.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl", hash = "sha256:26ef1d70928b6057ee3ca12583d73c63e05c49de8972d620c278a7b206581a8a", size = 28149, upload-time = "2022-02-24T08:12:25.24Z" }, +] + +[[package]] +name = "markdown" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -183,8 +585,68 @@ wheels = [ linkify = [ { name = "linkify-it-py" }, ] -plugins = [ - { name = "mdit-py-plugins" }, + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] @@ -209,22 +671,160 @@ wheels = [ ] [[package]] -name = "mutmut" -version = "3.4.0" +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, +] + +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, + { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + +[[package]] +name = "narwhals" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/84/897fe7b6406d436ef312e57e5a1a13b4a5e7e36d1844e8d934ce8880e3d3/narwhals-2.14.0.tar.gz", hash = "sha256:98be155c3599db4d5c211e565c3190c398c87e7bf5b3cdb157dece67641946e0", size = 600648, upload-time = "2025-12-16T11:29:13.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/3e/b8ecc67e178919671695f64374a7ba916cf0adbf86efedc6054f38b5b8ae/narwhals-2.14.0-py3-none-any.whl", hash = "sha256:b56796c9a00179bd757d15282c540024e1d5c910b19b8c9944d836566c030acf", size = 430788, upload-time = "2025-12-16T11:29:11.699Z" }, +] + +[[package]] +name = "nootnoot" +version = "3.4.6" source = { editable = "." } dependencies = [ { name = "click" }, { name = "coverage" }, { name = "libcst" }, { name = "pytest" }, + { name = "rich" }, { name = "setproctitle" }, { name = "textual" }, - { name = "toml", marker = "python_full_version < '3.11'" }, +] + +[package.optional-dependencies] +docs = [ + { name = "mkdocs" }, + { name = "pymdown-extensions" }, ] [package.dev-dependencies] dev = [ + { name = "coverage" }, + { name = "diff-cover" }, + { name = "hypothesis" }, + { name = "import-linter" }, + { name = "mkdocs" }, + { name = "pip-audit" }, + { name = "pymdown-extensions" }, + { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-randomly" }, + { name = "pytest-socket" }, + { name = "radon" }, + { name = "ruff" }, + { name = "rust-just" }, + { name = "showcov" }, + { name = "ty" }, + { name = "urllib3" }, + { name = "vulture" }, + { name = "wily" }, +] +docs = [ + { name = "mkdocs" }, + { name = "pymdown-extensions" }, ] [package.metadata] @@ -232,14 +832,51 @@ requires-dist = [ { name = "click", specifier = ">=8.0.0" }, { name = "coverage", specifier = ">=7.3.0" }, { name = "libcst", specifier = ">=1.8.5" }, + { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.6.0" }, + { name = "pymdown-extensions", marker = "extra == 'docs'", specifier = ">=10.12.1" }, { name = "pytest", specifier = ">=6.2.5" }, + { name = "rich", specifier = ">=14.2.0" }, { name = "setproctitle", specifier = ">=1.1.0" }, { name = "textual", specifier = ">=1.0.0" }, - { name = "toml", marker = "python_full_version < '3.11'", specifier = ">=0.10.2" }, ] +provides-extras = ["docs"] [package.metadata.requires-dev] -dev = [{ name = "pytest-asyncio", specifier = ">=1.0.0" }] +dev = [ + { name = "coverage", specifier = ">=7.12.0" }, + { name = "diff-cover", specifier = ">=9.7.2" }, + { name = "hypothesis", specifier = ">=6.148.6" }, + { name = "import-linter", specifier = ">=2.9" }, + { name = "mkdocs", specifier = ">=1.6.1" }, + { name = "pip-audit", specifier = ">=2.10.0" }, + { name = "pymdown-extensions", specifier = ">=10.19.1" }, + { name = "pytest", specifier = ">=9.0.1" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "pytest-randomly", specifier = ">=4.0.1" }, + { name = "pytest-socket", specifier = ">=0.7.0" }, + { name = "radon", specifier = ">=6.0.1" }, + { name = "ruff", specifier = ">=0.14.7" }, + { name = "rust-just", specifier = ">=1.43.1" }, + { name = "showcov", git = "https://github.com/josephcourtney/showcov.git?rev=main" }, + { name = "ty", specifier = ">=0.0.1a31" }, + { name = "urllib3", specifier = ">=2.6.0" }, + { name = "vulture", specifier = ">=2.14" }, + { name = "wily", specifier = ">=1.12.2" }, +] +docs = [ + { name = "mkdocs", specifier = ">=1.6.0" }, + { name = "pymdown-extensions", specifier = ">=10.12.1" }, +] + +[[package]] +name = "packageurl-python" +version = "0.17.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" }, +] [[package]] name = "packaging" @@ -250,13 +887,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pip" +version = "25.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/6e/74a3f0179a4a73a53d66ce57fdb4de0080a8baa1de0063de206d6167acc2/pip-25.3.tar.gz", hash = "sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343", size = 1803014, upload-time = "2025-10-25T00:55:41.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl", hash = "sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd", size = 1778622, upload-time = "2025-10-25T00:55:39.247Z" }, +] + +[[package]] +name = "pip-api" +version = "0.0.34" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/f1/ee85f8c7e82bccf90a3c7aad22863cc6e20057860a1361083cd2adacb92e/pip_api-0.0.34.tar.gz", hash = "sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625", size = 123017, upload-time = "2024-07-09T20:32:30.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/f7/ebf5003e1065fd00b4cbef53bf0a65c3d3e1b599b676d5383ccb7a8b88ba/pip_api-0.0.34-py3-none-any.whl", hash = "sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb", size = 120369, upload-time = "2024-07-09T20:32:29.099Z" }, +] + +[[package]] +name = "pip-audit" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachecontrol", extra = ["filecache"] }, + { name = "cyclonedx-python-lib" }, + { name = "packaging" }, + { name = "pip-api" }, + { name = "pip-requirements-parser" }, + { name = "platformdirs" }, + { name = "requests" }, + { name = "rich" }, + { name = "tomli" }, + { name = "tomli-w" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/89/0e999b413facab81c33d118f3ac3739fd02c0622ccf7c4e82e37cebd8447/pip_audit-2.10.0.tar.gz", hash = "sha256:427ea5bf61d1d06b98b1ae29b7feacc00288a2eced52c9c58ceed5253ef6c2a4", size = 53776, upload-time = "2025-12-01T23:42:40.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/f3/4888f895c02afa085630a3a3329d1b18b998874642ad4c530e9a4d7851fe/pip_audit-2.10.0-py3-none-any.whl", hash = "sha256:16e02093872fac97580303f0848fa3ad64f7ecf600736ea7835a2b24de49613f", size = 61518, upload-time = "2025-12-01T23:42:39.193Z" }, +] + +[[package]] +name = "pip-requirements-parser" +version = "32.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" }, +] + [[package]] name = "platformdirs" -version = "4.5.0" +version = "4.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[package]] +name = "plotly" +version = "6.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/05/1199e2a03ce6637960bc1e951ca0f928209a48cfceb57355806a88f214cf/plotly-6.5.0.tar.gz", hash = "sha256:d5d38224883fd38c1409bef7d6a8dc32b74348d39313f3c52ca998b8e447f5c8", size = 7013624, upload-time = "2025-11-17T18:39:24.523Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c3/3031c931098de393393e1f93a38dc9ed6805d86bb801acc3cf2d5bd1e6b7/plotly-6.5.0-py3-none-any.whl", hash = "sha256:5ac851e100367735250206788a2b1325412aa4a4917a4fe3e6f0bc5aa6f3d90a", size = 9893174, upload-time = "2025-11-17T18:39:20.351Z" }, ] [[package]] @@ -268,6 +982,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "progress" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/26/3b086f0c5d6c1c18c2430d6fac3a99d79553884ca6cdf759cf256dd43b7d/progress-1.6.1.tar.gz", hash = "sha256:c1ba719f862ce885232a759eab47971fe74dfc7bb76ab8a51ef5940bad35086c", size = 7164, upload-time = "2025-07-01T05:50:43.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/59/123aee44a039b212cfb8d90be1adf06496a99b313ee1683aadf90b3d9799/progress-1.6.1-py3-none-any.whl", hash = "sha256:5239f22f305c12fdc8ce6e0e47f70f21622a935e16eafc4535617112e7c7ea0b", size = 9761, upload-time = "2025-07-01T05:50:40.963Z" }, +] + +[[package]] +name = "py-serializable" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -277,33 +1012,105 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pymdown-extensions" +version = "10.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/2d/9f30cee56d4d6d222430d401e85b0a6a1ae229819362f5786943d1a8c03b/pymdown_extensions-10.19.1.tar.gz", hash = "sha256:4969c691009a389fb1f9712dd8e7bd70dcc418d15a0faf70acb5117d022f7de8", size = 847839, upload-time = "2025-12-14T17:25:24.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/35/b763e8fbcd51968329b9adc52d188fc97859f85f2ee15fe9f379987d99c5/pymdown_extensions-10.19.1-py3-none-any.whl", hash = "sha256:e8698a66055b1dc0dca2a7f2c9d0ea6f5faa7834a9c432e3535ab96c0c4e509b", size = 266693, upload-time = "2025-12-14T17:25:22.999Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c1/1d9de9aeaa1b89b0186e5fe23294ff6517fce1bc69149185577cd31016b2/pyparsing-3.3.1.tar.gz", hash = "sha256:47fad0f17ac1e2cad3de3b458570fbc9b03560aa029ed5e16ee5554da9a2251c", size = 1550512, upload-time = "2025-12-23T03:14:04.391Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/40/2614036cdd416452f5bf98ec037f38a1afb17f327cb8e6b652d4729e0af8/pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", size = 121793, upload-time = "2025-12-23T03:14:02.103Z" }, +] + [[package]] name = "pytest" -version = "8.2.0" +version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/9d/78b3785134306efe9329f40815af45b9215068d6ae4747ec0bc91ff1f4aa/pytest-8.2.0.tar.gz", hash = "sha256:d507d4482197eac0ba2bae2e9babf0672eb333017bcedaa5fb1a3d42c1174b3f", size = 1422883, upload-time = "2024-04-27T23:34:55.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/43/6b1debd95ecdf001bc46789a933f658da3f9738c65f32db3f4e8f2a4ca97/pytest-8.2.0-py3-none-any.whl", hash = "sha256:1733f0620f6cda4095bbf0d9ff8022486e91892245bb9e7d5542c018f612f233", size = 339229, upload-time = "2024-04-27T23:34:52.413Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] [[package]] name = "pytest-asyncio" -version = "1.0.0" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-randomly" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1d/258a4bf1109258c00c35043f40433be5c16647387b6e7cd5582d638c116b/pytest_randomly-4.0.1.tar.gz", hash = "sha256:174e57bb12ac2c26f3578188490bd333f0e80620c3f47340158a86eca0593cd8", size = 14130, upload-time = "2025-09-12T15:23:00.085Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/3e/a4a9227807b56869790aad3e24472a554b585974fe7e551ea350f50897ae/pytest_randomly-4.0.1-py3-none-any.whl", hash = "sha256:e0dfad2fd4f35e07beff1e47c17fbafcf98f9bf4531fd369d9260e2f858bfcb7", size = 8304, upload-time = "2025-09-12T15:22:58.946Z" }, +] + +[[package]] +name = "pytest-socket" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/d4/14f53324cb1a6381bef29d698987625d80052bb33932d8e7cbf9b337b17c/pytest_asyncio-1.0.0.tar.gz", hash = "sha256:d15463d13f4456e1ead2594520216b225a16f781e144f8fdf6c5bb4667c48b3f", size = 46960, upload-time = "2025-05-26T04:54:40.484Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/ff/90c7e1e746baf3d62ce864c479fd53410b534818b9437413903596f81580/pytest_socket-0.7.0.tar.gz", hash = "sha256:71ab048cbbcb085c15a4423b73b619a8b35d6a307f46f78ea46be51b1b7e11b3", size = 12389, upload-time = "2024-01-28T20:17:23.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/58/5d14cb5cb59409e491ebe816c47bf81423cd03098ea92281336320ae5681/pytest_socket-0.7.0-py3-none-any.whl", hash = "sha256:7e0f4642177d55d317bbd58fc68c6bd9048d6eadb2d46a89307fa9221336ce45", size = 6754, upload-time = "2024-01-28T20:17:22.105Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/05/ce271016e351fddc8399e546f6e23761967ee09c8c568bbfbecb0c150171/pytest_asyncio-1.0.0-py3-none-any.whl", hash = "sha256:4f024da9f1ef945e680dc68610b52550e36590a67fd31bb3b4943979a1f90ef3", size = 15976, upload-time = "2025-05-26T04:54:39.035Z" }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] @@ -312,24 +1119,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, @@ -370,6 +1159,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + [[package]] name = "pyyaml-ft" version = "8.0.0" @@ -394,6 +1195,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579, upload-time = "2025-06-10T15:32:14.34Z" }, ] +[[package]] +name = "radon" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "mando" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/6d/98e61600febf6bd929cf04154537c39dc577ce414bafbfc24a286c4fa76d/radon-6.0.1.tar.gz", hash = "sha256:d1ac0053943a893878940fedc8b19ace70386fc9c9bf0a09229a44125ebf45b5", size = 1874992, upload-time = "2023-03-26T06:24:38.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl", hash = "sha256:632cc032364a6f8bb1010a2f6a12d0f14bc7e5ede76585ef29dc0cecf4cd8859", size = 52784, upload-time = "2023-03-26T06:24:33.949Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + [[package]] name = "rich" version = "14.2.0" @@ -408,33 +1251,272 @@ wheels = [ ] [[package]] -name = "setproctitle" -version = "1.1" +name = "rich-click" +version = "1.9.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/7a/f0074ce6178bdfba69f824213527e80c6b7d3760c7bae706ca2bef6b8918/setproctitle-1.1.tar.gz", hash = "sha256:03c437f3a0e893b20a2511140625ce8e121d403f153234d1de0ea69d85b61ca5", size = 17102, upload-time = "2010-07-07T03:28:24.3Z" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/d1/b60ca6a8745e76800b50c7ee246fd73f08a3be5d8e0b551fc93c19fa1203/rich_click-1.9.5.tar.gz", hash = "sha256:48120531493f1533828da80e13e839d471979ec8d7d0ca7b35f86a1379cc74b6", size = 73927, upload-time = "2025-12-21T14:49:44.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/0a/d865895e1e5d88a60baee0fc3703eb111c502ee10c8c107516bc7623abf8/rich_click-1.9.5-py3-none-any.whl", hash = "sha256:9b195721a773b1acf0e16ff9ec68cef1e7d237e53471e6e3f7ade462f86c403a", size = 70580, upload-time = "2025-12-21T14:49:42.905Z" }, +] [[package]] -name = "textual" -version = "1.0.0" +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, + { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, + { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, +] + +[[package]] +name = "rust-just" +version = "1.45.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/83/8804ad2fbc12bbe05585fc13b525044c7f4c76b3f22368c9d7693e4b9e0d/rust_just-1.45.0.tar.gz", hash = "sha256:e17ed4a9d2e1d48ee024047371b71323c72194e4189cd7911184a3d4007cbe89", size = 1433575, upload-time = "2025-12-11T02:05:53.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/27/1357829369e9e037a66c3ab22ae35341d89cc05c66321377b1ab885cf661/rust_just-1.45.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f55a5ed6507189fb4c0c33821205f96739fab6c8c22c0264345749175bb6c59f", size = 1712097, upload-time = "2025-12-11T02:05:13.113Z" }, + { url = "https://files.pythonhosted.org/packages/44/67/7cb63895b3869282291294ea73386f517f7471b4767e05680784d0eef08a/rust_just-1.45.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a63628432f2b7e214cfb422013ddd7bf436993d8e5406e5bf1426ea8a97c794b", size = 1595130, upload-time = "2025-12-11T02:05:15.719Z" }, + { url = "https://files.pythonhosted.org/packages/69/69/f43363286b237b5ea1f43bb01792edd8bf5fbedb230f13b00a23bac34510/rust_just-1.45.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:037774833a914e6cf85771454dd623c9173dfa95f6c07033e528b4e484788f0d", size = 1677559, upload-time = "2025-12-11T02:05:18.197Z" }, + { url = "https://files.pythonhosted.org/packages/a9/af/34431539e1f072621a98e5891e436264f3028ca94267622c80ba8b11c2a3/rust_just-1.45.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:84d9d0e74e3e2f182002d9ed908c4dc9dac37bfa4515991df9c96f5824070aff", size = 1644684, upload-time = "2025-12-11T02:05:23.243Z" }, + { url = "https://files.pythonhosted.org/packages/7b/9d/b5a899977c3afa33997b1f1460f4aa198d8a4c496d98171c3468e25dd590/rust_just-1.45.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76e4bbfbfcd7e0d49cd3952f195188504285d1e04418e1e74cc3180d92babd2b", size = 1821927, upload-time = "2025-12-11T02:05:26.733Z" }, + { url = "https://files.pythonhosted.org/packages/00/d1/0c5c29c591cf4cf4345ba26e789f35c9f434fb20a1701df9ce148f7f2a5f/rust_just-1.45.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f43123b9ecc122222ac3cae69f2e698cd44afb1b3fdb03e342b56f916295cbd8", size = 1898840, upload-time = "2025-12-11T02:05:29.238Z" }, + { url = "https://files.pythonhosted.org/packages/51/5c/48e75a831926b9225a60ac602eb0dd8acd9002a6328ba02aaa91e0752e86/rust_just-1.45.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c9572941d9ee8a93e78973858561e5e01ce5f8e3eb466dbfe7dad226e73862ea", size = 1885445, upload-time = "2025-12-11T02:05:31.478Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d9/4c31345e96ca7072ce07123dda7c732421d7808e8be8382c87d72600b82a/rust_just-1.45.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5e7737429353aa43685671236994fb13eeac990056f487663d2fdfb77dd369d", size = 1806701, upload-time = "2025-12-11T02:05:34.032Z" }, + { url = "https://files.pythonhosted.org/packages/a0/25/ab55f3907fd479a1e28daaa178951d419799bae1dbab7ded3f09cae087b2/rust_just-1.45.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6fbe4634e3f4f7ba1d0b68d251da8e291377e1b75fecc1cf2dd8e89bfa577777", size = 1695387, upload-time = "2025-12-11T02:05:36.813Z" }, + { url = "https://files.pythonhosted.org/packages/ed/65/707ef48d339c2de4b6658e6c6d2c83f903fab5d9861b987833101cf2f6ac/rust_just-1.45.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:51d41861edd4872f430a3f8626ce5946581ab5f2f617767de9ff7f450b9d6498", size = 1667104, upload-time = "2025-12-11T02:05:39.3Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fe8466b95889203b93ff39b0ca92ca89af2330155bebee46165d481b0fa8/rust_just-1.45.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:00598b650295c97043175f27018c130a231cf15a62892231a42dfa8e7b4d70a2", size = 1811923, upload-time = "2025-12-11T02:05:42.279Z" }, + { url = "https://files.pythonhosted.org/packages/5b/1b/0c239cc3ff14ce6065ab6a1e879739e1e667cd6d482821679bc50bc77e3c/rust_just-1.45.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:22d5e4a963cd14c4e72c5733933a9478c4fe4b58684ac5c00a3da197b6cdbf70", size = 1871620, upload-time = "2025-12-11T02:05:45.603Z" }, + { url = "https://files.pythonhosted.org/packages/df/e3/3037f2db2cfddffd47f84c440dbf85fc83f96b3477c4c8b10364c1e4d261/rust_just-1.45.0-py3-none-win32.whl", hash = "sha256:33ba0085850fa0378ab479a4421ae79cf88e0e27589f401a63a26ce0c077ae6e", size = 1598481, upload-time = "2025-12-11T02:05:48.489Z" }, + { url = "https://files.pythonhosted.org/packages/62/3f/1ef435ecc57191be4d34a85d09790a30b3659fac320093366c62af7c56e9/rust_just-1.45.0-py3-none-win_amd64.whl", hash = "sha256:3b660701191a2bf413483b9b9d00f1372574e656ab7d0ab3a19c7b2e4321a538", size = 1768810, upload-time = "2025-12-11T02:05:51.114Z" }, +] + +[[package]] +name = "setproctitle" +version = "1.3.7" source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/48/49393a96a2eef1ab418b17475fb92b8fcfad83d099e678751b05472e69de/setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e", size = 27002, upload-time = "2025-09-05T12:51:25.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/f0/2dc88e842077719d7384d86cc47403e5102810492b33680e7dadcee64cd8/setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2dc99aec591ab6126e636b11035a70991bc1ab7a261da428491a40b84376654e", size = 18049, upload-time = "2025-09-05T12:49:36.241Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b4/50940504466689cda65680c9e9a1e518e5750c10490639fa687489ac7013/setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdd8aa571b7aa39840fdbea620e308a19691ff595c3a10231e9ee830339dd798", size = 13079, upload-time = "2025-09-05T12:49:38.088Z" }, + { url = "https://files.pythonhosted.org/packages/d0/99/71630546b9395b095f4082be41165d1078204d1696c2d9baade3de3202d0/setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2906b6c7959cdb75f46159bf0acd8cc9906cf1361c9e1ded0d065fe8f9039629", size = 32932, upload-time = "2025-09-05T12:49:39.271Z" }, + { url = "https://files.pythonhosted.org/packages/50/22/cee06af4ffcfb0e8aba047bd44f5262e644199ae7527ae2c1f672b86495c/setproctitle-1.3.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6915964a6dda07920a1159321dcd6d94fc7fc526f815ca08a8063aeca3c204f1", size = 33736, upload-time = "2025-09-05T12:49:40.565Z" }, + { url = "https://files.pythonhosted.org/packages/5c/00/a5949a8bb06ef5e7df214fc393bb2fb6aedf0479b17214e57750dfdd0f24/setproctitle-1.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff72899861c765bd4021d1ff1c68d60edc129711a2fdba77f9cb69ef726a8b6", size = 35605, upload-time = "2025-09-05T12:49:42.362Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3a/50caca532a9343828e3bf5778c7a84d6c737a249b1796d50dd680290594d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7cb05bd446687ff816a3aaaf831047fc4c364feff7ada94a66024f1367b448c", size = 33143, upload-time = "2025-09-05T12:49:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ca/14/b843a251296ce55e2e17c017d6b9f11ce0d3d070e9265de4ecad948b913d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3a57b9a00de8cae7e2a1f7b9f0c2ac7b69372159e16a7708aa2f38f9e5cc987a", size = 34434, upload-time = "2025-09-05T12:49:45.31Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b7/06145c238c0a6d2c4bc881f8be230bb9f36d2bf51aff7bddcb796d5eed67/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d8828b356114f6b308b04afe398ed93803d7fca4a955dd3abe84430e28d33739", size = 32795, upload-time = "2025-09-05T12:49:46.419Z" }, + { url = "https://files.pythonhosted.org/packages/ef/dc/ef76a81fac9bf27b84ed23df19c1f67391a753eed6e3c2254ebcb5133f56/setproctitle-1.3.7-cp312-cp312-win32.whl", hash = "sha256:b0304f905efc845829ac2bc791ddebb976db2885f6171f4a3de678d7ee3f7c9f", size = 12552, upload-time = "2025-09-05T12:49:47.635Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5b/a9fe517912cd6e28cf43a212b80cb679ff179a91b623138a99796d7d18a0/setproctitle-1.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:9888ceb4faea3116cf02a920ff00bfbc8cc899743e4b4ac914b03625bdc3c300", size = 13247, upload-time = "2025-09-05T12:49:49.16Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2f/fcedcade3b307a391b6e17c774c6261a7166aed641aee00ed2aad96c63ce/setproctitle-1.3.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3736b2a423146b5e62230502e47e08e68282ff3b69bcfe08a322bee73407922", size = 18047, upload-time = "2025-09-05T12:49:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/23/ae/afc141ca9631350d0a80b8f287aac79a76f26b6af28fd8bf92dae70dc2c5/setproctitle-1.3.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3384e682b158d569e85a51cfbde2afd1ab57ecf93ea6651fe198d0ba451196ee", size = 13073, upload-time = "2025-09-05T12:49:51.46Z" }, + { url = "https://files.pythonhosted.org/packages/87/ed/0a4f00315bc02510395b95eec3d4aa77c07192ee79f0baae77ea7b9603d8/setproctitle-1.3.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0564a936ea687cd24dffcea35903e2a20962aa6ac20e61dd3a207652401492dd", size = 33284, upload-time = "2025-09-05T12:49:52.741Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e4/adf3c4c0a2173cb7920dc9df710bcc67e9bcdbf377e243b7a962dc31a51a/setproctitle-1.3.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5d1cb3f81531f0eb40e13246b679a1bdb58762b170303463cb06ecc296f26d0", size = 34104, upload-time = "2025-09-05T12:49:54.416Z" }, + { url = "https://files.pythonhosted.org/packages/52/4f/6daf66394152756664257180439d37047aa9a1cfaa5e4f5ed35e93d1dc06/setproctitle-1.3.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a7d159e7345f343b44330cbba9194169b8590cb13dae940da47aa36a72aa9929", size = 35982, upload-time = "2025-09-05T12:49:56.295Z" }, + { url = "https://files.pythonhosted.org/packages/1b/62/f2c0595403cf915db031f346b0e3b2c0096050e90e0be658a64f44f4278a/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0b5074649797fd07c72ca1f6bff0406f4a42e1194faac03ecaab765ce605866f", size = 33150, upload-time = "2025-09-05T12:49:58.025Z" }, + { url = "https://files.pythonhosted.org/packages/a0/29/10dd41cde849fb2f9b626c846b7ea30c99c81a18a5037a45cc4ba33c19a7/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61e96febced3f61b766115381d97a21a6265a0f29188a791f6df7ed777aef698", size = 34463, upload-time = "2025-09-05T12:49:59.424Z" }, + { url = "https://files.pythonhosted.org/packages/71/3c/cedd8eccfaf15fb73a2c20525b68c9477518917c9437737fa0fda91e378f/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:047138279f9463f06b858e579cc79580fbf7a04554d24e6bddf8fe5dddbe3d4c", size = 32848, upload-time = "2025-09-05T12:50:01.107Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3e/0a0e27d1c9926fecccfd1f91796c244416c70bf6bca448d988638faea81d/setproctitle-1.3.7-cp313-cp313-win32.whl", hash = "sha256:7f47accafac7fe6535ba8ba9efd59df9d84a6214565108d0ebb1199119c9cbbd", size = 12544, upload-time = "2025-09-05T12:50:15.81Z" }, + { url = "https://files.pythonhosted.org/packages/36/1b/6bf4cb7acbbd5c846ede1c3f4d6b4ee52744d402e43546826da065ff2ab7/setproctitle-1.3.7-cp313-cp313-win_amd64.whl", hash = "sha256:fe5ca35aeec6dc50cabab9bf2d12fbc9067eede7ff4fe92b8f5b99d92e21263f", size = 13235, upload-time = "2025-09-05T12:50:16.89Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a4/d588d3497d4714750e3eaf269e9e8985449203d82b16b933c39bd3fc52a1/setproctitle-1.3.7-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:10e92915c4b3086b1586933a36faf4f92f903c5554f3c34102d18c7d3f5378e9", size = 18058, upload-time = "2025-09-05T12:50:02.501Z" }, + { url = "https://files.pythonhosted.org/packages/05/77/7637f7682322a7244e07c373881c7e982567e2cb1dd2f31bd31481e45500/setproctitle-1.3.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:de879e9c2eab637f34b1a14c4da1e030c12658cdc69ee1b3e5be81b380163ce5", size = 13072, upload-time = "2025-09-05T12:50:03.601Z" }, + { url = "https://files.pythonhosted.org/packages/52/09/f366eca0973cfbac1470068d1313fa3fe3de4a594683385204ec7f1c4101/setproctitle-1.3.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c18246d88e227a5b16248687514f95642505000442165f4b7db354d39d0e4c29", size = 34490, upload-time = "2025-09-05T12:50:04.948Z" }, + { url = "https://files.pythonhosted.org/packages/71/36/611fc2ed149fdea17c3677e1d0df30d8186eef9562acc248682b91312706/setproctitle-1.3.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7081f193dab22df2c36f9fc6d113f3793f83c27891af8fe30c64d89d9a37e152", size = 35267, upload-time = "2025-09-05T12:50:06.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/a4/64e77d0671446bd5a5554387b69e1efd915274686844bea733714c828813/setproctitle-1.3.7-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cc9b901ce129350637426a89cfd650066a4adc6899e47822e2478a74023ff7c", size = 37376, upload-time = "2025-09-05T12:50:07.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/ad9c664fe524fb4a4b2d3663661a5c63453ce851736171e454fa2cdec35c/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80e177eff2d1ec172188d0d7fd9694f8e43d3aab76a6f5f929bee7bf7894e98b", size = 33963, upload-time = "2025-09-05T12:50:09.056Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a36de7caf2d90c4c28678da1466b47495cbbad43badb4e982d8db8167ed4/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:23e520776c445478a67ee71b2a3c1ffdafbe1f9f677239e03d7e2cc635954e18", size = 35550, upload-time = "2025-09-05T12:50:10.791Z" }, + { url = "https://files.pythonhosted.org/packages/dd/68/17e8aea0ed5ebc17fbf03ed2562bfab277c280e3625850c38d92a7b5fcd9/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5fa1953126a3b9bd47049d58c51b9dac72e78ed120459bd3aceb1bacee72357c", size = 33727, upload-time = "2025-09-05T12:50:12.032Z" }, + { url = "https://files.pythonhosted.org/packages/b2/33/90a3bf43fe3a2242b4618aa799c672270250b5780667898f30663fd94993/setproctitle-1.3.7-cp313-cp313t-win32.whl", hash = "sha256:4a5e212bf438a4dbeece763f4962ad472c6008ff6702e230b4f16a037e2f6f29", size = 12549, upload-time = "2025-09-05T12:50:13.074Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0e/50d1f07f3032e1f23d814ad6462bc0a138f369967c72494286b8a5228e40/setproctitle-1.3.7-cp313-cp313t-win_amd64.whl", hash = "sha256:cf2727b733e90b4f874bac53e3092aa0413fe1ea6d4f153f01207e6ce65034d9", size = 13243, upload-time = "2025-09-05T12:50:14.146Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/43ac3a98414f91d1b86a276bc2f799ad0b4b010e08497a95750d5bc42803/setproctitle-1.3.7-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:80c36c6a87ff72eabf621d0c79b66f3bdd0ecc79e873c1e9f0651ee8bf215c63", size = 18052, upload-time = "2025-09-05T12:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2c/dc258600a25e1a1f04948073826bebc55e18dbd99dc65a576277a82146fa/setproctitle-1.3.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b53602371a52b91c80aaf578b5ada29d311d12b8a69c0c17fbc35b76a1fd4f2e", size = 13071, upload-time = "2025-09-05T12:50:19.061Z" }, + { url = "https://files.pythonhosted.org/packages/ab/26/8e3bb082992f19823d831f3d62a89409deb6092e72fc6940962983ffc94f/setproctitle-1.3.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fcb966a6c57cf07cc9448321a08f3be6b11b7635be502669bc1d8745115d7e7f", size = 33180, upload-time = "2025-09-05T12:50:20.395Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/ae692a20276d1159dd0cf77b0bcf92cbb954b965655eb4a69672099bb214/setproctitle-1.3.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46178672599b940368d769474fe13ecef1b587d58bb438ea72b9987f74c56ea5", size = 34043, upload-time = "2025-09-05T12:50:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/34/b2/6a092076324dd4dac1a6d38482bedebbff5cf34ef29f58585ec76e47bc9d/setproctitle-1.3.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f9e9e3ff135cbcc3edd2f4cf29b139f4aca040d931573102742db70ff428c17", size = 35892, upload-time = "2025-09-05T12:50:23.937Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1a/8836b9f28cee32859ac36c3df85aa03e1ff4598d23ea17ca2e96b5845a8f/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14c7eba8d90c93b0e79c01f0bd92a37b61983c27d6d7d5a3b5defd599113d60e", size = 32898, upload-time = "2025-09-05T12:50:25.617Z" }, + { url = "https://files.pythonhosted.org/packages/ef/22/8fabdc24baf42defb599714799d8445fe3ae987ec425a26ec8e80ea38f8e/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e64e98077fb30b6cf98073d6c439cd91deb8ebbf8fc62d9dbf52bd38b0c6ac0", size = 34308, upload-time = "2025-09-05T12:50:26.827Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/b9bee9de6c8cdcb3b3a6cb0b3e773afdb86bbbc1665a3bfa424a4294fda2/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b91387cc0f02a00ac95dcd93f066242d3cca10ff9e6153de7ee07069c6f0f7c8", size = 32536, upload-time = "2025-09-05T12:50:28.5Z" }, + { url = "https://files.pythonhosted.org/packages/37/0c/75e5f2685a5e3eda0b39a8b158d6d8895d6daf3ba86dec9e3ba021510272/setproctitle-1.3.7-cp314-cp314-win32.whl", hash = "sha256:52b054a61c99d1b72fba58b7f5486e04b20fefc6961cd76722b424c187f362ed", size = 12731, upload-time = "2025-09-05T12:50:43.955Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/acddbce90d1361e1786e1fb421bc25baeb0c22ef244ee5d0176511769ec8/setproctitle-1.3.7-cp314-cp314-win_amd64.whl", hash = "sha256:5818e4080ac04da1851b3ec71e8a0f64e3748bf9849045180566d8b736702416", size = 13464, upload-time = "2025-09-05T12:50:45.057Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/20886c8ff2e6d85e3cabadab6aab9bb90acaf1a5cfcb04d633f8d61b2626/setproctitle-1.3.7-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6fc87caf9e323ac426910306c3e5d3205cd9f8dcac06d233fcafe9337f0928a3", size = 18062, upload-time = "2025-09-05T12:50:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/9a/60/26dfc5f198715f1343b95c2f7a1c16ae9ffa45bd89ffd45a60ed258d24ea/setproctitle-1.3.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6134c63853d87a4897ba7d5cc0e16abfa687f6c66fc09f262bb70d67718f2309", size = 13075, upload-time = "2025-09-05T12:50:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/9c/980b01f50d51345dd513047e3ba9e96468134b9181319093e61db1c47188/setproctitle-1.3.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1403d2abfd32790b6369916e2313dffbe87d6b11dca5bbd898981bcde48e7a2b", size = 34744, upload-time = "2025-09-05T12:50:32.777Z" }, + { url = "https://files.pythonhosted.org/packages/86/b4/82cd0c86e6d1c4538e1a7eb908c7517721513b801dff4ba3f98ef816a240/setproctitle-1.3.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7c5bfe4228ea22373e3025965d1a4116097e555ee3436044f5c954a5e63ac45", size = 35589, upload-time = "2025-09-05T12:50:34.13Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4f/9f6b2a7417fd45673037554021c888b31247f7594ff4bd2239918c5cd6d0/setproctitle-1.3.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:585edf25e54e21a94ccb0fe81ad32b9196b69ebc4fc25f81da81fb8a50cca9e4", size = 37698, upload-time = "2025-09-05T12:50:35.524Z" }, + { url = "https://files.pythonhosted.org/packages/20/92/927b7d4744aac214d149c892cb5fa6dc6f49cfa040cb2b0a844acd63dcaf/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:96c38cdeef9036eb2724c2210e8d0b93224e709af68c435d46a4733a3675fee1", size = 34201, upload-time = "2025-09-05T12:50:36.697Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0c/fd4901db5ba4b9d9013e62f61d9c18d52290497f956745cd3e91b0d80f90/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:45e3ef48350abb49cf937d0a8ba15e42cee1e5ae13ca41a77c66d1abc27a5070", size = 35801, upload-time = "2025-09-05T12:50:38.314Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e3/54b496ac724e60e61cc3447f02690105901ca6d90da0377dffe49ff99fc7/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1fae595d032b30dab4d659bece20debd202229fce12b55abab978b7f30783d73", size = 33958, upload-time = "2025-09-05T12:50:39.841Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/c84bb045ebf8c6fdc7f7532319e86f8380d14bbd3084e6348df56bdfe6fd/setproctitle-1.3.7-cp314-cp314t-win32.whl", hash = "sha256:02432f26f5d1329ab22279ff863c83589894977063f59e6c4b4845804a08f8c2", size = 12745, upload-time = "2025-09-05T12:50:41.377Z" }, + { url = "https://files.pythonhosted.org/packages/08/b6/3a5a4f9952972791a9114ac01dfc123f0df79903577a3e0a7a404a695586/setproctitle-1.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:cbc388e3d86da1f766d8fc2e12682e446064c01cea9f88a88647cfe7c011de6a", size = 13469, upload-time = "2025-09-05T12:50:42.67Z" }, +] + +[[package]] +name = "showcov" +version = "0.1.4" +source = { git = "https://github.com/josephcourtney/showcov.git?rev=main#a82ddfb287ab04b60ebcea0a625630e51565bbdc" } dependencies = [ - { name = "markdown-it-py", extra = ["linkify", "plugins"] }, - { name = "platformdirs" }, + { name = "click" }, + { name = "configparser" }, + { name = "defusedxml" }, + { name = "jsonschema" }, + { name = "more-itertools" }, + { name = "pathspec" }, { name = "rich" }, - { name = "typing-extensions" }, + { name = "rich-click" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/b6/59b1de04bb4dca0f21ed7ba0b19309ed7f3f5de4396edf20cc2855e53085/textual-1.0.0.tar.gz", hash = "sha256:bec9fe63547c1c552569d1b75d309038b7d456c03f86dfa3706ddb099b151399", size = 1532733, upload-time = "2024-12-12T10:42:03.286Z" } + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/bb/5fb6656c625019cd653d5215237d7cd6e0b12e7eae4195c3d1c91b2136fc/textual-1.0.0-py3-none-any.whl", hash = "sha256:2d4a701781c05104925e463ae370c630567c70c2880e92ab838052e3e23c986f", size = 660456, upload-time = "2024-12-12T10:42:00.375Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] -name = "toml" -version = "0.10.2" +name = "smmap" +version = "5.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "tabulate" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, +] + +[[package]] +name = "textual" +version = "6.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/06/906f86bbc59ec7cd3fb424250e19ce670406d1f28e49e86c2221e9fd7ed2/textual-6.11.0.tar.gz", hash = "sha256:08237ebda0cfbbfd1a4e2fd3039882b35894a73994f6f0fcc12c5b0d78acf3cc", size = 1584292, upload-time = "2025-12-18T10:48:38.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/fc/5e2988590ff2e0128eea6446806c904445a44e17256c67141573ea16b5a5/textual-6.11.0-py3-none-any.whl", hash = "sha256:9e663b73ed37123a9b13c16a0c85e09ef917a4cfded97814361ed5cccfa40f89", size = 714886, upload-time = "2025-12-18T10:48:36.269Z" }, ] [[package]] @@ -443,14 +1525,6 @@ version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, - { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, - { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, - { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, - { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, - { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, - { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, @@ -486,6 +1560,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, ] +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "ty" +version = "0.0.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/9d/59e955cc39206a0d58df5374808785c45ec2a8a2a230eb1638fbb4fe5c5d/ty-0.0.8.tar.gz", hash = "sha256:352ac93d6e0050763be57ad1e02087f454a842887e618ec14ac2103feac48676", size = 4828477, upload-time = "2025-12-29T13:50:07.193Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/2b/dd61f7e50a69c72f72c625d026e9ab64a0db62b2dd32e7426b520e2429c6/ty-0.0.8-py3-none-linux_armv6l.whl", hash = "sha256:a289d033c5576fa3b4a582b37d63395edf971cdbf70d2d2e6b8c95638d1a4fcd", size = 9853417, upload-time = "2025-12-29T13:50:08.979Z" }, + { url = "https://files.pythonhosted.org/packages/90/72/3f1d3c64a049a388e199de4493689a51fc6aa5ff9884c03dea52b4966657/ty-0.0.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:788ea97dc8153a94e476c4d57b2551a9458f79c187c4aba48fcb81f05372924a", size = 9657890, upload-time = "2025-12-29T13:50:27.867Z" }, + { url = "https://files.pythonhosted.org/packages/71/d1/08ac676bd536de3c2baba0deb60e67b3196683a2fabebfd35659d794b5e9/ty-0.0.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1b5f1f3d3e230f35a29e520be7c3d90194a5229f755b721e9092879c00842d31", size = 9180129, upload-time = "2025-12-29T13:50:22.842Z" }, + { url = "https://files.pythonhosted.org/packages/af/93/610000e2cfeea1875900f73a375ba917624b0a008d4b8a6c18c894c8dbbc/ty-0.0.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6da9ed377fbbcec0a3b60b2ca5fd30496e15068f47cef2344ba87923e78ba996", size = 9683517, upload-time = "2025-12-29T13:50:18.658Z" }, + { url = "https://files.pythonhosted.org/packages/05/04/bef50ba7d8580b0140be597de5cc0ba9a63abe50d3f65560235f23658762/ty-0.0.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7d0a2bdce5e701d19eb8d46d9da0fe31340f079cecb7c438f5ac6897c73fc5ba", size = 9676279, upload-time = "2025-12-29T13:50:25.207Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b9/2aff1ef1f41b25898bc963173ae67fc8f04ca666ac9439a9c4e78d5cc0ff/ty-0.0.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef9078799d26d3cc65366e02392e2b78f64f72911b599e80a8497d2ec3117ddb", size = 10073015, upload-time = "2025-12-29T13:50:35.422Z" }, + { url = "https://files.pythonhosted.org/packages/df/0e/9feb6794b6ff0a157c3e6a8eb6365cbfa3adb9c0f7976e2abdc48615dd72/ty-0.0.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:54814ac39b4ab67cf111fc0a236818155cf49828976152378347a7678d30ee89", size = 10961649, upload-time = "2025-12-29T13:49:58.717Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3b/faf7328b14f00408f4f65c9d01efe52e11b9bcc4a79e06187b370457b004/ty-0.0.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4baf0a80398e8b6c68fa36ff85045a50ede1906cd4edb41fb4fab46d471f1d4", size = 10676190, upload-time = "2025-12-29T13:50:01.11Z" }, + { url = "https://files.pythonhosted.org/packages/64/a5/cfeca780de7eeab7852c911c06a84615a174d23e9ae08aae42a645771094/ty-0.0.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ac8e23c3faefc579686799ef1649af8d158653169ad5c3a7df56b152781eeb67", size = 10438641, upload-time = "2025-12-29T13:50:29.664Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8d/8667c7e0ac9f13c461ded487c8d7350f440cd39ba866d0160a8e1b1efd6c/ty-0.0.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b558a647a073d0c25540aaa10f8947de826cb8757d034dd61ecf50ab8dbd77bf", size = 10214082, upload-time = "2025-12-29T13:50:31.531Z" }, + { url = "https://files.pythonhosted.org/packages/f8/11/e563229870e2c1d089e7e715c6c3b7605a34436dddf6f58e9205823020c2/ty-0.0.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8c0104327bf480508bd81f320e22074477df159d9eff85207df39e9c62ad5e96", size = 9664364, upload-time = "2025-12-29T13:50:05.443Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ad/05b79b778bf5237bcd7ee08763b226130aa8da872cbb151c8cfa2e886203/ty-0.0.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:496f1cb87261dd1a036a5609da80ee13de2e6ee4718a661bfa2afb91352fe528", size = 9679440, upload-time = "2025-12-29T13:50:11.289Z" }, + { url = "https://files.pythonhosted.org/packages/12/b5/23ba887769c4a7b8abfd1b6395947dc3dcc87533fbf86379d3a57f87ae8f/ty-0.0.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2c488031f92a075ae39d13ac6295fdce2141164ec38c5d47aa8dc24ee3afa37e", size = 9808201, upload-time = "2025-12-29T13:50:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/f8/90/5a82ac0a0707db55376922aed80cd5fca6b2e6d6e9bcd8c286e6b43b4084/ty-0.0.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:90d6f08c5982fa3e802b8918a32e326153519077b827f91c66eea4913a86756a", size = 10313262, upload-time = "2025-12-29T13:50:03.306Z" }, + { url = "https://files.pythonhosted.org/packages/14/f7/ff97f37f0a75db9495ddbc47738ec4339837867c4bfa145bdcfbd0d1eb2f/ty-0.0.8-py3-none-win32.whl", hash = "sha256:d7f460ad6fc9325e9cc8ea898949bbd88141b4609d1088d7ede02ce2ef06e776", size = 9254675, upload-time = "2025-12-29T13:50:33.35Z" }, + { url = "https://files.pythonhosted.org/packages/af/51/eba5d83015e04630002209e3590c310a0ff1d26e1815af204a322617a42e/ty-0.0.8-py3-none-win_amd64.whl", hash = "sha256:1641fb8dedc3d2da43279d21c3c7c1f80d84eae5c264a1e8daa544458e433c19", size = 10131382, upload-time = "2025-12-29T13:50:13.719Z" }, + { url = "https://files.pythonhosted.org/packages/38/1c/0d8454ff0f0f258737ecfe84f6e508729191d29663b404832f98fa5626b7/ty-0.0.8-py3-none-win_arm64.whl", hash = "sha256:ec74f022f315bede478ecae1277a01ab618e6500c1d68450d7883f5cd6ed554a", size = 9636374, upload-time = "2025-12-29T13:50:16.344Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -503,3 +1611,63 @@ sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e wheels = [ { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, ] + +[[package]] +name = "urllib3" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, +] + +[[package]] +name = "vulture" +version = "2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/25/925f35db758a0f9199113aaf61d703de891676b082bd7cf73ea01d6000f7/vulture-2.14.tar.gz", hash = "sha256:cb8277902a1138deeab796ec5bef7076a6e0248ca3607a3f3dee0b6d9e9b8415", size = 58823, upload-time = "2024-12-08T17:39:43.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/56/0cc15b8ff2613c1d5c3dc1f3f576ede1c43868c1bc2e5ccaa2d4bcd7974d/vulture-2.14-py2.py3-none-any.whl", hash = "sha256:d9a90dba89607489548a49d557f8bac8112bd25d3cbc8aeef23e860811bd5ed9", size = 28915, upload-time = "2024-12-08T17:39:40.573Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "wily" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorlog" }, + { name = "gitpython" }, + { name = "plotly" }, + { name = "progress" }, + { name = "radon" }, + { name = "tabulate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/f1/d354dd245147e90d3806f4f01f97e8471e236de4d88a53bdfebea86ef376/wily-1.12.2.tar.gz", hash = "sha256:e13810f60cb436b7dc0aa6a70a584db297bbecf8553ba2981b822e4160d06ba4", size = 1444245, upload-time = "2019-03-14T11:08:57.272Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/c0/7e1d1a3786ffc641c6532f76f1c2b18676fd8d34ee9480c40ca31eddd3a2/wily-1.12.2-py3-none-any.whl", hash = "sha256:3c9a9f931d051ca7874df035c0518aa67569ac68da06c6cca0823626277130b9", size = 97377, upload-time = "2019-03-14T11:08:50.5Z" }, +]