From 6350fed76268dae476e7feba50afcf2579729c13 Mon Sep 17 00:00:00 2001 From: Ashlen Date: Fri, 23 Oct 2026 03:25:18 -0600 Subject: [PATCH 1/7] build: refresh patched development tooling Pin uv 0.13.4 across maintainer and generated tooling, and update the maintainer's locked virtualenv to 21.14.9 for the current security audit. Keep runtime declarations and verification thresholds unchanged. --- .github/workflows/test-template.yml | 10 +++++----- CHANGELOG.md | 1 + mise.toml | 2 +- template/mise.toml.jinja | 2 +- .../workflows/ci.yml.jinja | 2 +- ...able_mutation_tests %}mutation.yml{% endif %}.jinja | 2 +- ...r enable_sha_pin_policy %}scan.yml{% endif %}.jinja | 2 +- uv.lock | 6 +++--- 8 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test-template.yml b/.github/workflows/test-template.yml index cc50633..b8c2403 100644 --- a/.github/workflows/test-template.yml +++ b/.github/workflows/test-template.yml @@ -28,7 +28,7 @@ jobs: persist-credentials: false - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: - version: "0.11.23" + version: "0.13.4" enable-cache: true python-version: ${{ matrix.python-version }} - uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4.0.0 @@ -46,7 +46,7 @@ jobs: persist-credentials: false - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: - version: "0.11.23" + version: "0.13.4" enable-cache: true - name: zizmor (workflow audit) env: @@ -62,7 +62,7 @@ jobs: persist-credentials: false - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: - version: "0.11.23" + version: "0.13.4" enable-cache: true - name: Install dependencies run: uv sync --locked @@ -78,7 +78,7 @@ jobs: persist-credentials: false - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: - version: "0.11.23" + version: "0.13.4" enable-cache: true - name: Install dependencies run: uv sync --locked @@ -98,7 +98,7 @@ jobs: persist-credentials: false - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: - version: "0.11.23" + version: "0.13.4" enable-cache: true - name: pip-audit run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 210bf89..7a2e71c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Pin uv 0.13.4 in generated mise and CI configuration to use the patched TLS trust bundle. - Bump the generated project's ruff to `>=0.17,<0.18` (and the shipped `ruff-pre-commit` hook to v0.17.1). Under `ruff_ruleset = "all"`, ignore `CPY001`, which ruff 0.17 stabilised and would otherwise demand a copyright header in every file. diff --git a/mise.toml b/mise.toml index e8966c6..e2bc40a 100644 --- a/mise.toml +++ b/mise.toml @@ -1,6 +1,6 @@ [tools] python = "3.15.0" -uv = "0.11.23" +uv = "0.13.4" just = "1.59.0" copier = "9.19.1" gitleaks = "8.30.1" diff --git a/template/mise.toml.jinja b/template/mise.toml.jinja index b9d96b3..1f7b8bf 100644 --- a/template/mise.toml.jinja +++ b/template/mise.toml.jinja @@ -1,6 +1,6 @@ [tools] python = "{{ python_version }}" -uv = "0.11.23" +uv = "0.13.4" just = "1.50.0" copier = "9.15.2" {%- if enable_scanners %} diff --git a/template/{% if not in_existing_repo %}.github{% endif %}/workflows/ci.yml.jinja b/template/{% if not in_existing_repo %}.github{% endif %}/workflows/ci.yml.jinja index b36661f..354428e 100644 --- a/template/{% if not in_existing_repo %}.github{% endif %}/workflows/ci.yml.jinja +++ b/template/{% if not in_existing_repo %}.github{% endif %}/workflows/ci.yml.jinja @@ -27,7 +27,7 @@ jobs: persist-credentials: false - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: - version: "0.11.23" + version: "0.13.4" enable-cache: true python-version: {% raw %}${{ matrix.python-version }}{% endraw %} - uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4.0.0 diff --git a/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_mutation_tests %}mutation.yml{% endif %}.jinja b/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_mutation_tests %}mutation.yml{% endif %}.jinja index 7b7a32d..f4be5d2 100644 --- a/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_mutation_tests %}mutation.yml{% endif %}.jinja +++ b/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_mutation_tests %}mutation.yml{% endif %}.jinja @@ -24,7 +24,7 @@ jobs: persist-credentials: false - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: - version: "0.11.23" + version: "0.13.4" enable-cache: true python-version: "3.13" - run: uv sync --locked diff --git a/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_scanners or enable_dependency_audit or enable_sha_pin_policy %}scan.yml{% endif %}.jinja b/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_scanners or enable_dependency_audit or enable_sha_pin_policy %}scan.yml{% endif %}.jinja index 862e2d6..9c9cc11 100644 --- a/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_scanners or enable_dependency_audit or enable_sha_pin_policy %}scan.yml{% endif %}.jinja +++ b/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_scanners or enable_dependency_audit or enable_sha_pin_policy %}scan.yml{% endif %}.jinja @@ -24,7 +24,7 @@ jobs: persist-credentials: false - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: - version: "0.11.23" + version: "0.13.4" enable-cache: true {%- if enable_sha_pin_policy %} - name: zizmor (workflow audit) diff --git a/uv.lock b/uv.lock index a80053f..c1317e5 100644 --- a/uv.lock +++ b/uv.lock @@ -705,7 +705,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.14.7" +version = "21.14.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -714,9 +714,9 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/3f/935cf1757643381fb7f7cc98197b4bc119223d9c2cf37dc3b03ad2a4455f/virtualenv-21.14.7.tar.gz", hash = "sha256:5f427d56f39eb7e7447d641dd4b7ab1e9c92793f498f35f26da2e4972c5546ae", size = 5471741, upload-time = "2026-10-12T01:27:31.842Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/04/2f69ba1207ce59630ecea8ebfdaf5c8f9a14726f2408ccd145a18d1ef619/virtualenv-21.14.9.tar.gz", hash = "sha256:4d1bee354cd81c820da3bee56e38be3426490f8d1fc54e10ea5b2072ebad8ef8", size = 5500672, upload-time = "2026-10-17T16:09:42.345Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/5b/799c3e5b8e04d5fa1c9b61a4c1f5079603e747205c7311b92abd30283df5/virtualenv-21.14.7-py3-none-any.whl", hash = "sha256:3769219a308c5d2f093e7729621ad12a6346b5767a7ec4338414e2a0fb0c526b", size = 5487587, upload-time = "2026-10-12T01:27:29.37Z" }, + { url = "https://files.pythonhosted.org/packages/64/e5/9a3918219745e8bdd64fe0fd9a2d7ce4fffde9268fbc791634b7d4779de9/virtualenv-21.14.9-py3-none-any.whl", hash = "sha256:ac51744da66ae56ff814b087d65096c94de698ec520adc624b108b679655809b", size = 5494452, upload-time = "2026-10-17T16:09:40.584Z" }, ] [[package]] From 6093409dedfe6cec64a340d1bf73adc31d267ae7 Mon Sep 17 00:00:00 2001 From: Ashlen Date: Fri, 23 Oct 2026 03:27:20 -0600 Subject: [PATCH 2/7] feat: add isolated Copier workflow automation Prepare and resume explicit Python development plans in new, existing, workbench or disposable modes. Bind verification to product files, checks, Git context and durable receipts before reviewed copy-back. Add the generated helper project, contract regressions, property tests and CI wiring. Keep live agent installation and template upgrades outside the helper's authority. --- .github/workflows/test-template.yml | 20 + AGENTS.md | 23 +- README.md | 10 + automation/.copier-answers.yml | 22 + automation/.editorconfig | 11 + automation/.gitignore | 13 + automation/.gitleaks.toml | 3 + automation/.semgrep.yml | 6 + automation/AGENTS.md | 52 + automation/CHANGELOG.md | 32 + automation/CLAUDE.md | 1 + automation/LICENSE | 13 + automation/README.md | 320 +++ automation/justfile | 50 + automation/mise.toml | 7 + automation/pyproject.toml | 125 ++ .../src/kickstarter_workflow/__init__.py | 6 + .../src/kickstarter_workflow/__main__.py | 39 + .../src/kickstarter_workflow/environment.py | 115 ++ automation/src/kickstarter_workflow/files.py | 135 ++ .../src/kickstarter_workflow/git_context.py | 145 ++ .../src/kickstarter_workflow/integrity.py | 71 + automation/src/kickstarter_workflow/policy.py | 53 + .../src/kickstarter_workflow/process.py | 148 ++ automation/src/kickstarter_workflow/py.typed | 0 .../src/kickstarter_workflow/records.py | 113 + automation/src/kickstarter_workflow/setup.py | 238 +++ .../src/kickstarter_workflow/transfer.py | 332 +++ .../src/kickstarter_workflow/verification.py | 225 ++ .../src/kickstarter_workflow/workflow.py | 172 ++ automation/tests/__init__.py | 1 + automation/tests/conftest.py | 94 + automation/tests/policy/test_gates.py | 43 + automation/tests/property/conftest.py | 48 + .../tests/property/test_review_properties.py | 84 + .../property/test_workflow_properties.py | 176 ++ automation/tests/unit/test_acceptance.py | 156 ++ automation/tests/unit/test_cli.py | 25 + automation/tests/unit/test_configuration.py | 82 + automation/tests/unit/test_destination.py | 94 + automation/tests/unit/test_environment.py | 131 ++ automation/tests/unit/test_git_worktrees.py | 80 + automation/tests/unit/test_intent.py | 31 + automation/tests/unit/test_provenance.py | 34 + automation/tests/unit/test_resume.py | 64 + .../tests/unit/test_review_concurrency.py | 94 + automation/tests/unit/test_review_git.py | 187 ++ .../tests/unit/test_review_regressions.py | 85 + automation/tests/unit/test_safety.py | 214 ++ automation/tests/unit/test_transfer.py | 147 ++ automation/tests/unit/test_workflow.py | 156 ++ automation/uv.lock | 1813 +++++++++++++++++ justfile | 7 +- tests/policy/test_automation.py | 34 + workflow/SKILL.md | 49 + workflow/references/host-routing.md | 37 + 56 files changed, 6457 insertions(+), 9 deletions(-) create mode 100644 automation/.copier-answers.yml create mode 100644 automation/.editorconfig create mode 100644 automation/.gitignore create mode 100644 automation/.gitleaks.toml create mode 100644 automation/.semgrep.yml create mode 100644 automation/AGENTS.md create mode 100644 automation/CHANGELOG.md create mode 100644 automation/CLAUDE.md create mode 100644 automation/LICENSE create mode 100644 automation/README.md create mode 100644 automation/justfile create mode 100644 automation/mise.toml create mode 100644 automation/pyproject.toml create mode 100644 automation/src/kickstarter_workflow/__init__.py create mode 100644 automation/src/kickstarter_workflow/__main__.py create mode 100644 automation/src/kickstarter_workflow/environment.py create mode 100644 automation/src/kickstarter_workflow/files.py create mode 100644 automation/src/kickstarter_workflow/git_context.py create mode 100644 automation/src/kickstarter_workflow/integrity.py create mode 100644 automation/src/kickstarter_workflow/policy.py create mode 100644 automation/src/kickstarter_workflow/process.py create mode 100644 automation/src/kickstarter_workflow/py.typed create mode 100644 automation/src/kickstarter_workflow/records.py create mode 100644 automation/src/kickstarter_workflow/setup.py create mode 100644 automation/src/kickstarter_workflow/transfer.py create mode 100644 automation/src/kickstarter_workflow/verification.py create mode 100644 automation/src/kickstarter_workflow/workflow.py create mode 100644 automation/tests/__init__.py create mode 100644 automation/tests/conftest.py create mode 100644 automation/tests/policy/test_gates.py create mode 100644 automation/tests/property/conftest.py create mode 100644 automation/tests/property/test_review_properties.py create mode 100644 automation/tests/property/test_workflow_properties.py create mode 100644 automation/tests/unit/test_acceptance.py create mode 100644 automation/tests/unit/test_cli.py create mode 100644 automation/tests/unit/test_configuration.py create mode 100644 automation/tests/unit/test_destination.py create mode 100644 automation/tests/unit/test_environment.py create mode 100644 automation/tests/unit/test_git_worktrees.py create mode 100644 automation/tests/unit/test_intent.py create mode 100644 automation/tests/unit/test_provenance.py create mode 100644 automation/tests/unit/test_resume.py create mode 100644 automation/tests/unit/test_review_concurrency.py create mode 100644 automation/tests/unit/test_review_git.py create mode 100644 automation/tests/unit/test_review_regressions.py create mode 100644 automation/tests/unit/test_safety.py create mode 100644 automation/tests/unit/test_transfer.py create mode 100644 automation/tests/unit/test_workflow.py create mode 100644 automation/uv.lock create mode 100644 tests/policy/test_automation.py create mode 100644 workflow/SKILL.md create mode 100644 workflow/references/host-routing.md diff --git a/.github/workflows/test-template.yml b/.github/workflows/test-template.yml index b8c2403..38e56b3 100644 --- a/.github/workflows/test-template.yml +++ b/.github/workflows/test-template.yml @@ -13,6 +13,26 @@ concurrency: cancel-in-progress: true jobs: + automation: + runs-on: ubuntu-latest + timeout-minutes: 180 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: "0.13.4" + enable-cache: true + python-version: "3.11" + - uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4.0.0 + - name: Verify automation helper + env: + TMPDIR: ${{ runner.temp }} + HYPOTHESIS_PROFILE: ci + run: just automation-ci + test: strategy: fail-fast: false diff --git a/AGENTS.md b/AGENTS.md index 3b0c0a4..39a8523 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,21 +2,28 @@ This repo is a Copier template. `template/` holds the generated project as `.jinja`. +`automation/` is a separate, Copier-generated helper project outside the rendered +template. Read its `AGENTS.md` before editing it. The shared agent procedure is +`workflow/SKILL.md`; its host-routing guide preserves existing implementation and +review authority. Editing those repository artifacts does not install live skills. +The maintainer-harness details below apply to root `tests/`; the helper has its own +lockfile, strict configuration and substantive product tests. + ## Run every gate (`just ci`) ```bash -just ci # fmt-check + lint + typecheck + test + policy + audit, then "ci: all gates passed" +just ci # root gates + automation-ci, then "ci: all gates passed" ``` -`just ci` is the complete local gate — `ci: fmt-check lint typecheck test policy audit` ending `@echo "ci: all gates passed"`, with `verify` a bare alias (`verify: ci`). It **mirrors** `template/justfile.jinja`'s `ci`: the template chains `policy` and `audit` when `enable_policy_tests`/`enable_dependency_audit` are on (they are), so the faithful maintainer recipe includes both. `test` is the heavy generation matrix, so `just ci` needs a roomy `TMPDIR` — the default 4G tmpfs `/tmp` can overflow (export e.g. `TMPDIR=/path/to/roomy/dir` first). The fast inner loop is `just fmt-check lint typecheck` (one invocation, no matrix, no network); there is deliberately **no** `check:` recipe (the template ships none). `audit` is non-hermetic (it queries the OSV/PyPI advisory DB), so `just ci` reaches the network exactly as `just audit` does; scanners (`just scan`) deliberately stay **off** `ci` (CI-only), matching the template, as does the changelog check (`changelog.yml`, no recipe — see "Changelog check"). +`just ci` is the complete local gate — `ci: fmt-check lint typecheck test policy audit automation-ci` ending `@echo "ci: all gates passed"`, with `verify` a bare alias (`verify: ci`). The maintainer gates mirror `template/justfile.jinja`'s `ci`: the template chains `policy` and `audit` when `enable_policy_tests`/`enable_dependency_audit` are on (they are), so the maintainer recipe includes both. The additive `automation-ci` dependency syncs the helper's locked environment and runs its complete generated gate. `test` is the heavy generation matrix, so `just ci` needs a roomy `TMPDIR` — the default 4G tmpfs `/tmp` can overflow (export e.g. `TMPDIR=/path/to/roomy/dir` first). The fast inner loop is `just fmt-check lint typecheck` (one invocation, no matrix, no network); there is deliberately **no** `check:` recipe (the template ships none). `audit` is non-hermetic (it queries the OSV/PyPI advisory DB), so `just ci` reaches the network exactly as `just audit` does; scanners (`just scan`) deliberately stay **off** `ci` (CI-only), matching the template, as does the changelog check (`changelog.yml`, no recipe — see "Changelog check"). This maintainer `just ci` is distinct from the **downstream's** `just ci` referenced under "Run the tests": `just test` renders each project in the answer matrix and runs *its* `just ci` — a different, generated recipe. -CI does **not** run `just ci`. It keeps parallel per-gate jobs (`test`/`typecheck`/`lint`/`scan`, plus the PR-only `changelog` workflow — see "Changelog check"), each inlining its command; only `test` fans out over the OS×Python matrix, and the `scan` job runs `uvx`/`mise exec` (not `uv run`); because every gate is enforced independently (audit via the `scan` job's `pip-audit` step), `just ci` here is the local reproduction of the PR gate, not the CI entry point. Pointing the CI jobs at these recipes (the gap-audit "candidate F") is deferred: the `scan` job aggregates pip-audit + semgrep + gitleaks as three steps that don't map onto the `audit`+`scan` recipe split, so it is a real restructure, not a one-line swap, and the follow-up must reconcile the recipe encoding with the parallel jobs. +CI does **not** run the root `just ci`. It keeps parallel per-gate jobs (`test`/`typecheck`/`lint`/`scan`, plus `automation` running `just automation-ci` and the PR-only `changelog` workflow — see "Changelog check"), each inlining its command; only `test` fans out over the OS×Python matrix, and the `scan` job runs `uvx`/`mise exec` (not `uv run`); because every gate is enforced independently (audit via the `scan` job's `pip-audit` step), `just ci` here is the local reproduction of the PR gate, not the CI entry point. Pointing the CI jobs at these recipes (the gap-audit "candidate F") is deferred: the `scan` job aggregates pip-audit + semgrep + gitleaks as three steps that don't map onto the `audit`+`scan` recipe split, so it is a real restructure, not a one-line swap, and the follow-up must reconcile the recipe encoding with the parallel jobs. **Forward-sync:** the policy-tests layer (gap #4) has landed — `policy` is now a `ci` member (see "Policy gate"). When the property-tests layer (gap #9) lands, add `fuzz` to **both** the `ci` recipe **and** this section, to keep mirroring the template's conditional `ci` (`fmt-check lint typecheck test{% if enable_property_tests %} fuzz{% endif %}{% if enable_policy_tests %} policy{% endif %}{% if enable_dependency_audit %} audit{% endif %}`). -Deliberate divergences from `template/justfile.jinja`'s `ci`: **none in the gate list** — `ci: fmt-check lint typecheck test policy audit`, `verify: ci`, and the `@echo` are exact. The divergences are structural: the maintainer's CI never runs `just ci` (parallel per-gate jobs — only `test` matrixed — with inline `uv run`/`uvx`/`mise exec` commands, vs the downstream `ci.yml`'s single `just ci`); there is no `check:` fast split (use `just fmt-check lint typecheck`); and candidate F is deferred. (The `audit` recipe's dropped `--no-dev` is a separate divergence documented under "Dependency audit", not a `ci`-list divergence.) +Deliberate divergences from `template/justfile.jinja`'s `ci`: the additive `automation-ci` dependency verifies this repository's helper project; all original maintainer gates, `verify: ci`, and the `@echo` are preserved. The divergences are structural: the maintainer's CI never runs `just ci` (parallel per-gate jobs — only `test` matrixed — with inline `uv run`/`uvx`/`mise exec` commands, vs the downstream `ci.yml`'s single `just ci`); there is no `check:` fast split (use `just fmt-check lint typecheck`); and candidate F is deferred. (The `audit` recipe's dropped `--no-dev` is a separate divergence documented under "Dependency audit", not a `ci`-list divergence.) ## Run the tests @@ -28,7 +35,7 @@ just test # renders the answer matrix, installs each project, runs its `just ## Type-check this repo ```bash -just typecheck # basedpyright recommended over tests/ (the only Python surface; no src/) +just typecheck # basedpyright recommended over the maintainer tests/ harness ``` The maintainer harness is held to the same `recommended` bar the template ships. CI enforces it (the `typecheck` job in `.github/workflows/test-template.yml`); `failOnWarnings` makes any finding fail the build. @@ -41,7 +48,7 @@ just fmt # ruff check --fix . + ruff format . (apply safe fixes) just fmt-check # ruff format --check . (CI's format gate) ``` -The maintainer harness runs the same full `select=["ALL"]` ruleset the template ships, scoped to `tests/` (the only Python surface; `template/` is Jinja). Every config-level ignore is load-bearing and audited — there are **no inline `# noqa`**. CI enforces `ruff check` + `ruff format --check` (the `lint` job in `.github/workflows/test-template.yml`). +The maintainer harness runs the same full `select=["ALL"]` ruleset the template ships over `tests/` (`template/` is Jinja). Ruff discovers the nested helper's own equally strict configuration under `automation/`. Every config-level ignore is load-bearing and audited — there are **no inline `# noqa`**. CI enforces `ruff check` + `ruff format --check` (the `lint` job in `.github/workflows/test-template.yml`). ## Pre-commit @@ -62,7 +69,7 @@ Deliberate divergences from the template's `.pre-commit-config.yaml` (`template/ just scan # out-of-band secret + SAST scan: semgrep (no-eval) + gitleaks (full history) ``` -`just scan` runs semgrep's `no-eval` rule and a gitleaks **full-history** secret scan (`.gitleaks.toml` = default ruleset). It is out-of-band (chained into no recipe), but CI enforces it: the `scan` job in `.github/workflows/test-template.yml` is a blocking PR gate. gitleaks is pinned in `mise.toml` (`gitleaks = "8.30.1"`) and installed in CI via `jdx/mise-action` + `mise exec`; semgrep runs via `uvx semgrep@1.167.0` (no dep, like zizmor). **semgrep scans non-test Python only** — its built-in `.semgrepignore` excludes `tests/`, and there is no `src/`, so on this repo it currently scans **0 files** (a forward guard that mirrors the shipped gate and fires the moment any non-test Python is added at root); gitleaks scans the whole tree + full history regardless of language and is the substantive gate here. Never pass semgrep `--config auto` (it drops the pinned rule and needs metrics on); never hardcode the gitleaks version in CI (install via `mise exec`). +`just scan` runs semgrep's `no-eval` rule and a gitleaks **full-history** secret scan (`.gitleaks.toml` = default ruleset). It is out-of-band (chained into no recipe), but CI enforces it: the `scan` job in `.github/workflows/test-template.yml` is a blocking PR gate. gitleaks is pinned in `mise.toml` (`gitleaks = "8.30.1"`) and installed in CI via `jdx/mise-action` + `mise exec`; semgrep runs via `uvx semgrep@1.167.0` (no dep, like zizmor). **semgrep scans non-test Python only** — its built-in `.semgrepignore` excludes `tests/`. The new helper under `automation/src/` makes this a substantive product scan; verify its measured file inventory rather than carrying forward the earlier empty-scan result. Gitleaks scans the whole tree + full history regardless of language. Never pass semgrep `--config auto` (it drops the pinned rule and needs metrics on); never hardcode the gitleaks version in CI (install via `mise exec`). Deliberate divergences from the template's `scan.yml` (`template/{% if not in_existing_repo %}.github{% endif %}/workflows/…scan.yml….jinja`): the maintainer folds scanning into the existing `test-template.yml` as a sibling `scan` job (the template consolidates into a standalone `scan.yml`), matching the one-workflow / per-tool layout and letting the existing zizmor job audit it (the changelog check is the one exception: its trigger types need a separate workflow — see "Changelog check"); zizmor stays its own job here rather than a step in `scan` (already dogfooded standalone). The CI `mise-action` comment drops the template's "kept fresh by Renovate" note; the maintainer's `mise.toml` pin is Renovate-managed too (see "Renovate"), but the comment names only the single-source-of-truth property. @@ -74,7 +81,7 @@ Renovate re-derives only the `mise.toml` gitleaks pin (see "Renovate"); every ot just audit # dependency vulnerability audit: pip-audit over the full locked graph (also a `just ci` member) ``` -`just audit` exports the fully-resolved lockfile (`uv export --frozen --no-emit-project --no-hashes -o requirements-audit.txt`) and runs `uvx pip-audit@2.10.1 -r requirements-audit.txt`. It is chained into `just ci` (the local aggregate — see "Run every gate") and independently enforced in CI: the `pip-audit` step in the `scan` job of `.github/workflows/test-template.yml` is a blocking PR gate. Unlike semgrep — which scans 0 files here — pip-audit is the **substantive** dependency gate: it audits the real ~35-package dev+transitive graph (copier / pytest / ruff / basedpyright / pre-commit / plumbum / pyyaml + transitives), because **`--no-dev` is dropped**. Never restore `--no-dev`: `package = false` puts every dep in the dev group, so `--no-dev` would export 0 packages and pass vacuously. `requirements-audit.txt` is `rm`'d on success, gitignored, and lingers (harmlessly) only after a failed run. pip-audit is the one **non-hermetic** step in the `scan` job — it queries the OSV/PyPI advisory DB, so this gate can turn red on a newly-published CVE (or an advisory-DB outage) independent of any code change; a `--frozen` **export** failure instead means `uv.lock` is stale (re-lock), distinct from a pip-audit non-zero (a CVE). pip-audit runs via `uvx` (no pyproject dep, like semgrep/zizmor). +`just audit` exports the fully-resolved lockfile (`uv export --frozen --no-emit-project --no-hashes -o requirements-audit.txt`) and runs `uvx pip-audit@2.10.1 -r requirements-audit.txt`. It is chained into `just ci` (the local aggregate — see "Run every gate") and independently enforced in CI: the `pip-audit` step in the `scan` job of `.github/workflows/test-template.yml` is a blocking PR gate. Pip-audit audits the maintainer's real ~35-package dev+transitive graph (copier / pytest / ruff / basedpyright / pre-commit / plumbum / pyyaml + transitives), because **`--no-dev` is dropped**. Never restore `--no-dev`: `package = false` puts every dep in the dev group, so `--no-dev` would export 0 packages and pass vacuously. `requirements-audit.txt` is `rm`'d on success, gitignored, and lingers (harmlessly) only after a failed run. pip-audit is the one **non-hermetic** step in the `scan` job — it queries the OSV/PyPI advisory DB, so this gate can turn red on a newly-published CVE (or an advisory-DB outage) independent of any code change; a `--frozen` **export** failure instead means `uv.lock` is stale (re-lock), distinct from a pip-audit non-zero (a CVE). pip-audit runs via `uvx` (no pyproject dep, like semgrep/zizmor). Deliberate divergences from the template's dependency-audit layer: `--no-dev` is dropped (above); pip-audit runs via `uvx pip-audit@2.10.1` in both the recipe and CI with **no** pyproject dep (the template adds `pip-audit>=2.10` to its dev group and runs `uv run pip-audit` locally); it is folded into `test-template.yml`'s `scan` job as a step (the template ships it in a standalone `scan.yml`); and, like the template, `audit` is chained into `just ci` (see "Run every gate") while additionally enforced in CI as the `pip-audit` step in the `scan` job. diff --git a/README.md b/README.md index 02b0ba6..2d3a27b 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,16 @@ Beyond the local recipes, several toggles also emit GitHub Actions workflows: `e See `AGENTS.md` for the template extension contract (adding guardrail layers, releasing, CI pinning rules). +## Agent development workflow + +The separate [`automation/`](automation/README.md) project provides the helper for +isolated Python development. The portable [`workflow/SKILL.md`](workflow/SKILL.md) +defines preparation, verification and reviewed return for Hermes, Claude Code and +Codex while preserving each host's task authority. Neither is rendered into +downstream projects. Live skill installation and fresh-session discovery are +separate from building and testing the helper; see the +[routing guide](workflow/references/host-routing.md). + ## License ISC — see [`LICENSE`](LICENSE). Generated projects get their own license, chosen at copy time (`MIT`, `Apache-2.0`, `ISC`, or `proprietary`). diff --git a/automation/.copier-answers.yml b/automation/.copier-answers.yml new file mode 100644 index 0000000..0b6966f --- /dev/null +++ b/automation/.copier-answers.yml @@ -0,0 +1,22 @@ +# This file is auto-generated by Copier. Manual edits are overwritten by `copier update`. +_commit: v0.2.0-47-g37adbee +_src_path: https://github.com/maybebyte/python-kickstarter.git +author_email: dev@anthes.is +author_name: Ashlen +coverage_floor: 85 +description: Prepare and verify isolated Python development workspaces. +enable_changelog: true +enable_dependency_audit: true +enable_mutation_tests: true +enable_policy_tests: true +enable_property_tests: true +enable_renovate: true +enable_scanners: true +enable_sha_pin_policy: true +in_existing_repo: true +license: ISC +package_name: kickstarter_workflow +project_name: Kickstarter Workflow +project_type: library +python_version: '3.11' +ruff_ruleset: all diff --git a/automation/.editorconfig b/automation/.editorconfig new file mode 100644 index 0000000..08c80de --- /dev/null +++ b/automation/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 + +[*.{yml,yaml,json}] +indent_size = 2 diff --git a/automation/.gitignore b/automation/.gitignore new file mode 100644 index 0000000..a2d7a16 --- /dev/null +++ b/automation/.gitignore @@ -0,0 +1,13 @@ +.venv/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.hypothesis/ +.mutmut-cache +mutants/ +dist/ +artifacts/ +htmlcov/ +coverage.xml +.coverage +requirements-audit.txt diff --git a/automation/.gitleaks.toml b/automation/.gitleaks.toml new file mode 100644 index 0000000..f9fab4a --- /dev/null +++ b/automation/.gitleaks.toml @@ -0,0 +1,3 @@ +# gitleaks config — extend the default ruleset. +[extend] +useDefault = true diff --git a/automation/.semgrep.yml b/automation/.semgrep.yml new file mode 100644 index 0000000..15d233d --- /dev/null +++ b/automation/.semgrep.yml @@ -0,0 +1,6 @@ +rules: + - id: no-eval + languages: [python] + severity: ERROR + message: Avoid eval(); it executes arbitrary code. + pattern: eval(...) diff --git a/automation/AGENTS.md b/automation/AGENTS.md new file mode 100644 index 0000000..eb2565b --- /dev/null +++ b/automation/AGENTS.md @@ -0,0 +1,52 @@ +# Agent contract — Kickstarter Workflow + +`README.md` is the human entry point; this file is the agent contract. + +## Hard rules + +| Never | Instead | +| --- | --- | +| `# type: ignore` / `# noqa` to dodge a finding | fix the root cause | +| lower `coverage_floor` to pass | add tests; the policy test blocks a silent drop | +| `git commit --no-verify` | make the gate pass | +| weaken a test to make it green | fix the code under test | + +## Start here + +```bash +uv sync # install deps + dev group +just # list recipes +just ci # the complete gate — must be green before pushing +``` + +## The gate + +`just ci` is the COMPLETE fast gate: fmt-check, lint, typecheck, test+coverage, fuzz, policy, audit. Out-of-band: `just mutate` (mutation, non-gating); `just scan` (semgrep + gitleaks). + +## Add a behavior test + +Put it in `tests/unit/`. Test the contract, not internals; keep it deterministic. Use `tests/unit/test_cli.py` as a public-interface example. + +## Add a property test + +Put it in `tests/property/`. It MUST set `pytestmark = pytest.mark.property` and use `@given`. Runs via `just fuzz`. See `tests/property/test_workflow_properties.py` for public-interface properties with isolated example state. + +## Mutation coverage + +`just mutate`, then read survivors from `mutants/mutmut-cicd-stats.json`; kill them with `tests/unit/` cases. Non-gating by default. + +## Lint and types + +`just lint` / `just fmt` / `just typecheck`. Never inline-dodge a real finding. + +## Dependencies + +`uv add ` (runtime) or `uv add --dev ` (tooling). `uv.lock` is committed; CI runs `uv sync --locked`. + +## Changelog + +A PR that changes `src/` or `pyproject.toml` must add an entry under `## [Unreleased]` in `CHANGELOG.md`. Promote the entries under a version heading in each release commit. + +## Meta-guardrail + +Changing a gate (coverage floor, ruff select, type mode, Action pins) requires editing the matching test in `tests/policy/` in the SAME commit. diff --git a/automation/CHANGELOG.md b/automation/CHANGELOG.md new file mode 100644 index 0000000..8d5c9b3 --- /dev/null +++ b/automation/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Fixed + +- Exclude Git control files from product inventories so linked worktrees and separate-git-dir sources prepare without copying administrative pointers. +- Keep mapped test paths literal through generated recipes and refuse unsupported parser syntax before tool execution. +- Preserve mapped initializer bytes and prepare all-new source/test declarations without supplying their payloads. +- Enroll complete isolated candidates in disposable Git indexes; retain and validate local reachable history/refs without changing source Git state, and reject unsupported Git context requirements. +- Serialize shared project metadata independently of task ownership, preserving pending-write recovery blockers. +- Validate each verification receipt's passing status and content-addressed identity before accepting it, including after destination checks. +- Refuse destination-check mutations and retain the intended payload, observed effects and recovery journal. +- Require a new receipt-bound host review when reconciliation changes candidate bytes or modes. +- Prepare declared static PEP 621 dependencies and default uv_build packages before freezing gates, including task-local locked environments and package assets. +- Validate retained inventories, baselines, environment and setup provenance on resumption. +- Preserve existing generated gate choices and coverage thresholds while freezing them against weakening. +- Pin Copier 9.15.2 and uv 0.13.4 as development-only integration-test prerequisites. +- Refresh the locked certifi and virtualenv development dependencies to their patched releases. + +### Added + +- JSON CLI and Python interface for task-owned prepare/resume, verification, reviewed return and explicit cleanup. +- Real pinned Copier generation, existing-project reuse, disposable promotion, complete product mappings and private external records. +- Helper-owned raw command results, real product test/branch-coverage evidence, candidate/mode/config/tool-bound receipts, and fail-closed stale/failed/interrupted evidence handling. +- Reviewed selective transfer with compatible three-way reconciliation, conflict protection, destination-layout checks and durable interrupted-return recovery. +- Maintained integration, safety and Hypothesis property tests replacing generated clamp examples; no runtime Python dependencies or live agent installation. diff --git a/automation/CLAUDE.md b/automation/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/automation/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/automation/LICENSE b/automation/LICENSE new file mode 100644 index 0000000..8f577ac --- /dev/null +++ b/automation/LICENSE @@ -0,0 +1,13 @@ +Copyright (c) Ashlen + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/automation/README.md b/automation/README.md new file mode 100644 index 0000000..b1813d6 --- /dev/null +++ b/automation/README.md @@ -0,0 +1,320 @@ +# Kickstarter Workflow + +A stdlib-only, POSIX helper for preparing isolated Python development, running real +checks, and returning only a reviewed product payload. It does not commit, publish, +upgrade a template, install hooks, or install agent skills. + +## Develop + +```bash +uv sync --locked +just ci +``` + +The unchanged generated gate includes Ruff ALL, recommended basedpyright, +unit tests with **85% branch coverage**, Hypothesis, policy tests, and the runtime +dependency audit. Runtime dependencies are empty; that audit is not a development +dependency audit. The locked development group includes Copier **9.15.2** and uv +**0.13.4**. Integration tests invoke those tools and require Git and just on PATH, +network access for task-local development environments, and a roomy `TMPDIR`. +No helper test installs hooks. + +## Interface + +```bash +uv run kickstarter-workflow --state "$HOME/.local/state/kickstarter-workflow" prepare plan.json +uv run kickstarter-workflow --state "$HOME/.local/state/kickstarter-workflow" verify plan.json +uv run kickstarter-workflow --state "$HOME/.local/state/kickstarter-workflow" return review.json +``` + +`python -m kickstarter_workflow` is equivalent. The Python interface is +`Workflow(Path(state_directory)).dispatch(operation, request_dict)`. It returns the +same JSON-compatible object as the CLI. Operations are `prepare`, `resume`, +`status`, `verify`, `return`, and `cleanup`. A completed operation has status +`prepared`, `exempt`, `verified`, `returned`, or `cleaned` and CLI exit zero. +`blocked`, `preparing`, `verifying`, `review_required`, and `returning` are **not** +completed delivery; the CLI exits 2. `review_required` retains a verified reconciled +candidate but requires receipt-bound host review before any transfer. Missing state +is a blocker, never a fresh implicit task. Invoke concurrent tasks +in separate processes; the Python interface is not a thread-safe process launcher. + +Preparation is not verification. Workbench verification is not destination +validation. The result includes the workspace, retained plan, actual tool identities, +and, after verification, receipt identity and measured product tests/coverage. +Diagnostics are local metadata containing absolute paths, not a portable identity +or a shareable report. Keep the durable state directory outside source repositories +and outside scratch/cache expiry policies. It must be owned by the caller, mode 0700. + +### Plan + +All mappings are explicit **files**, not directory globs. Include runtime code, +maintained tests, imported local dependencies, fixtures, assets and relevant context. +The full original tree is separately inventoried and copied for repository checks. +The scope review is a human-reviewed declaration, not proof of adequate coverage. +An omitted dependency, meaningless assertion, or missing CI obligation remains a +review finding even when mechanically selected checks pass. + +```json +{ + "project": "sample", + "task": "fix-double", + "mode": "workbench", + "source": "/absolute/product-repository", + "template": { + "source": "https://github.com/maybebyte/python-kickstarter.git", + "revision": "37adbeeea53a242ed25db602a35b9d719d95c8ae", + "cache": "/absolute/clean-detached-template-cache" + }, + "tools": { + "git": "/absolute/bin/git", + "copier": "/absolute/bin/copier", + "uv": "/absolute/bin/uv", + "just": "/absolute/bin/just" + }, + "answers": { + "project_name": "Sample", + "package_name": "sample", + "author_name": "The project maintainer", + "author_email": "maintainer@example.invalid", + "description": "The established product purpose.", + "license": "ISC", + "python_version": "3.11", + "project_type": "library" + }, + "mapping": [ + {"source": "app.py", "target": "product/app.py", "role": "source"}, + {"source": "test_app.py", "target": "product/test_app.py", "role": "test"}, + {"source": "README.md", "target": "product/README.md", "role": "context"} + ], + "repository_checks": [ + ["uv", "run", "--project", "{workspace}", "pytest", "--override-ini=addopts=", "test_app.py"] + ], + "scope_review": "Complete product slice and all repository obligations reviewed." +} +``` + +Resolve identity, license, Python compatibility and application/library choices from +project evidence. Do not copy personal identity into work projects or select template +answers that downgrade a product contract. The helper requires explicit answers and +lets Copier validate supported values. Tool paths can be discovered with the Python +`kickstarter_workflow.process.default_tools()` utility; discovery installs nothing. + +The cache must be a clean, detached canonical Git clone at the exact reviewed commit. +Moving branches, tags alone, dirty files, noncanonical remotes, symlink paths and +unapproved revisions are refused. This initial release approves only the commit above; +adding another reviewed revision is a helper policy change, not an automatic update. +The source cache is never mutated by the helper. Copier renders with `--skip-tasks`; +Git initialization is explicitly scoped with an empty template and disabled hooks. +A generated answers file is preserved byte-for-byte. Copier may record a Git describe +value there; the separate task record contains the resolved full approved commit. + +Preparation creates and locks the task-local environment before reporting readiness. +Resume and repeated preparation validate the retained source/scaffold inventories, +baseline, exact Git roots, environment, tool/template identities and frozen gate +configuration. Missing or changed setup evidence blocks readiness while preserving +the workspace; ordinary product edits remain resumable. + +Modes: + +- **`workbench`**: existing, non-Kickstarter repository. Generate under the private + task directory. Targets must start with `product/`. The helper adapts the generated + gate's targets and existing test-only lint exceptions to those files. It does not + copy its scaffold back to the product. +- **`existing`**: existing Kickstarter repository. Copy its complete relevant tree + into an isolated task repository; validate and preserve its answers/configuration. + Use identical source/target mappings. No regeneration or upgrade occurs. +- **`new`**: generate at the explicitly authorized, nonexistent `source` destination. + Use identical source/target mappings. Set `isolate_git: true` only when explicitly + authorizing a new independent Git root beneath an existing ancestor. Unchanged + generated example tests cannot establish product verification. This mode already + occupies its destination and has no copy-back operation. +- **`disposable`**: only `project`, `task`, `mode` and a nonempty `reason` are needed. + The exemption is durable but cannot verify or return production work. Re-enter + `prepare` with a production plan to promote it; the exemption history is retained. + +Use `new: true` on a mapping to declare a **new** maintained source/test/context file +before writing it in the workbench. An existing source at that path is refused. +All declared source/test targets must exist before verification. Ordinary file +replacement/addition is supported; file deletion, symlink payloads, special files, +hardlinked payloads and scaffold adoption are deliberately refused. + +Mapped test paths preserve spaces, BMP Unicode and literal shell punctuation through +the generated recipe. Test mappings containing `{`, `}`, `[`, `]`, `*`, `?`, or +`::` are refused before tool execution: just interpolation, Ruff glob patterns and +pytest selectors are not literal filename transport. Shell quoting alone cannot +neutralize those parsers. A mapped `product/__init__.py` is never replaced with +helper scaffolding; a declared new initializer remains absent until authored. +Non-BMP Unicode test paths currently fail during setup and are not supported. + +A source must be the exact declared Git root. Nested generated subprojects and +repository migration need an explicitly arranged isolated repository-root layout; +this helper will not silently widen a scan to an enclosing repository or home. + +### Existing product dependencies and packaging + +For a workbench source with `pyproject.toml`, declare its environment before +preparation and map that file to `product/pyproject.toml` as context: + +```json +{ + "environment": { + "project": "pyproject.toml", + "groups": [], + "extras": [] + } +} +``` + +Select only groups/extras required by the declared product slice. The helper reads +the existing static PEP 621 dependencies and Python compatibility, adding the +dependencies to workbench development setup without changing the product's runtime +declarations. Selected groups and extras must contain flat dependency lists. + +Local packaged products currently require the default `uv_build` layout and a +complete layout-preserving mapping: every relevant source, test, metadata file and +asset maps to `product/`. The workbench installs that local package +editable with its declared build requirements. Destination checks import from the +destination layout rather than silently continuing to test the workbench copy. + +Dynamic metadata, custom `tool.uv` configuration, other build backends and legacy +requirements/setup formats block preparation. The helper reports these limits +rather than guessing dependency or packaging behavior. A dependency-free script +without project metadata needs no environment declaration. Installing existing +declared dependencies in the isolated environment does not authorize changing them +in the product. + +### Check plan and evidence + +The helper always executes generated `just ci`, a separate measured product test +run, and the declared repository checks in an isolated complete repository snapshot. +A repository `justfile` adds mandatory `just ci` even if omitted from the list. +An existing `.kickstarter-checks.json` containing `{"checks": [["just", "ci"]]}` +adds repository-owned commands; this helper never creates that file in a product. +Declare remaining mandatory CI commands and changed-risk extras in +`repository_checks` **before editing**, including any development audit, SAST or +mutation work required by review. Commands are argument arrays, not shell strings; +the executable must be in the explicit toolbox. `{workspace}` in declared command +arguments selects the generated development environment without changing the +repository check's working directory. + +Before isolated checks, the helper builds a disposable discovery index from every +inventoried candidate file, including untracked and ignored product files. Blobs +use literal bytes without Git filters. This index deliberately represents the +whole candidate, not the source's staged/unstaged distinction. The source index, +local configuration and hooks are never copied for execution; original Git state +is not rewritten. Local reachable history and exact refs/HEAD are retained in a content-bound Git +bundle and restored independently for repository copies and reused projects. +Unborn repositories honestly retain no commits; the helper does not manufacture +history. Receipts bind semantic indexes and refs, and repository checks that change +their measured files or Git inputs are refused. Changed source refs or damaged +retained bundles invalidate readiness and require a new task or explicit recovery. + +The reviewed check plan may specify `git_requirements`, defaulting to +`["index", "history", "refs"]`, which is also the supported set. Requirements for +reflogs, source staging state, local configuration, remote access or other Git +semantics must be declared and are blocked, not emulated. Shallow/partial clones, +grafts and replacement objects are unsupported. This covers complete **locally +reachable** history, not unreachable objects or unfetched remote history. Reviewers +must assess executable checks against this boundary; the helper cannot infer +arbitrary scripts' unstated requirements. Destination checks retain the actual +destination Git context without rewriting its index. + +Prepared template gate configuration, lockfile and policy tests are frozen: changing +their bodies, thresholds or configuration requires a newly reviewed preparation, +not a success marker. Existing generated projects retain their actual enabled gates +and approved thresholds, including legitimately disabled optional gates. New +workbenches retain the strict defaults. The helper does not waive required failures. +Subsequent environment sync uses the lock. Lock/sync and developer +downloads stay within task workspaces/designated caches; no runtime dependency +change is authorized by this workflow. + +Every tool invocation has a pre-execution `running` record, exact argv/cwd, raw merged +stdout/stderr and final exit status. A verification receipt binds all relevant +candidate bytes **and modes**, the plan, uv lockfile and actual executable identities; +it fingerprints the raw command records, JUnit results and branch coverage JSON. +The separate product run must discover successful maintained tests and execute every +mapped source file at the approved coverage threshold: at least 85% for new +workbenches, or the existing generated project's preserved floor. Missing, empty, failed, interrupted, +modified or stale evidence does not authorize return. Restoring identical verified +inputs can reuse the unchanged receipt; a failed new verification supersedes it. + +Repository checks and product tests are executable project code. This is a workflow +control for reviewed code, **not a sandbox against a hostile same-user process** or +an untrusted repository. Private records prevent accidental shared mutable task +state; a process with the same filesystem privileges can still deliberately tamper +with code/state or bypass the workflow. Independent code/configuration review is +required to judge gate adequacy and execution authority. + +### Reviewed return + +```json +{ + "project": "sample", + "task": "fix-double", + "review": { + "receipt": "the exact receipt returned by verify", + "reviewer": "independent review reference", + "reason": "Reviewed product delta and complete maintained regressions.", + "allowlist": ["app.py", "test_app.py"] + } +} +``` + +The allowlist uses **source/destination** names, must contain every changed mapped +file, and cannot introduce unmapped payloads. Generated configuration, locks, hooks, +CI and reports do not travel by default; this release refuses their adoption rather +than inventing authorization. Review is an explicit caller attestation bound to a +receipt, not proof that an independent reviewer really performed the review. + +Destination drift is compared with the retained source snapshot. Textually compatible +three-way changes refresh the snapshot and rerun gates. When that changes candidate +bytes or modes, `return` stops before transfer with `status: "review_required"`, a new +`receipt`, `previous_receipt`, and `reconciliation_diff`. The host's independent +reviewer must inspect the new candidate's behavioral intent and submit an updated +review bound to the new receipt. The old review is rejected. This review can proceed +within existing task authority for compatible intent; behavioral conflicts or unclear +ownership require a user decision. Textual merging and coverage alone do not prove +semantic compatibility. Unchanged-candidate drift can proceed without another review. + +Content, add/delete or incompatible mode conflicts block with work retained. +Unrelated newer destination files are preserved. All required repository checks and +measured product tests rerun in the destination layout after transfer. The helper +then checks that the reviewed payload, unrelated destination files, workspace inputs +and bound evidence remain unchanged. A check that rewrites a file cannot turn that +changed candidate into an accepted delivery. Publication remains the host's separate +authority. + +Transfers journal the intended before/after states before the first replacement. +`status` reports actual effects after interruption, even if a replacement happened +before its journal update. Retry with the **same** review skips files already applied, +refuses unexpected newer content and reruns destination checks. It never blindly +replays the writes. File writes and immediate-parent entries are fsynced; this is not +a multi-file filesystem transaction or a power-loss guarantee. Cooperating process +ownership and immediately repeated destination checks do not provide atomic CAS +against arbitrary simultaneous external writers. + +On failed destination validation, `transferred` records files matching the payload +before checks, while `applied` reports those still matching afterward. +`destination_changes` identifies changes made during the checks, and +`destination_validated` remains false. Inspect the retained journal and actual +effects before recovery; a failed check is not a rollback. + +`cleanup` is explicit, only for returned/exempt/resolved work. It removes the owned +workspace, never a source project, and retains records/raw evidence. Unresolved +work, malformed/partial records and pending atomic writes require a decision and +are preserved. There is no cleanup daemon. + +Task ownership remains independent. Only shared project-metadata inspection and +publication use a short project-level lock; competing publishers wait rather than +mistaking an active write for abandoned recovery state. A pending write left after +the publisher exits blocks readiness and cleanup until explicitly reconciled. + +## Evidence and platform scope + +Tests preserve literal examples, real Copier/Git integration, path/receipt/lifecycle +Hypothesis properties, failed-gate cases, compatible/conflicting drift, and a real +SIGINT-at-replacement child-process recovery case. Recorded repair verification +includes the full helper gate on Linux/Python 3.14.6 and focused regressions on +Python 3.11.15. These results do not establish arbitrary crash timing, hostile +concurrent writers, Windows support or live native agent discovery. Native +Hermes/Claude/Codex routing and installation are separate rollout work. diff --git a/automation/justfile b/automation/justfile new file mode 100644 index 0000000..3b559b4 --- /dev/null +++ b/automation/justfile @@ -0,0 +1,50 @@ +set shell := ["bash", "-eu", "-o", "pipefail", "-c"] + +default: + @just --list + +# The complete local gate — every PR-blocking check reproducible locally. +ci: fmt-check lint typecheck test fuzz policy audit + @echo "ci: all gates passed" + +verify: ci + +# Lint-fix BEFORE format: ruff's fixes (import sort, SIM/UP/C4 rewrites) can emit +# unformatted code, so the formatter must run last or `fmt-check` may reject `fmt`'s output. +fmt: + uv run ruff check --fix . + uv run ruff format . + +fmt-check: + uv run ruff format --check . + +lint: + uv run ruff check . + +typecheck: + uv run basedpyright + +test: + uv run pytest -m "not property" tests/unit + +fuzz: + HYPOTHESIS_PROFILE="${HYPOTHESIS_PROFILE:-dev}" uv run pytest --no-cov -m property tests/property + +policy: + uv run pytest --no-cov tests/policy + +audit: + uv export --frozen --no-emit-project --no-dev --no-hashes -o requirements-audit.txt + uv run pip-audit -r requirements-audit.txt + rm -f requirements-audit.txt + +# Slow / out-of-band (also runs in CI on its own cron, non-gating). +mutate: + uv run mutmut run || true + uv run mutmut export-cicd-stats + uv run mutmut results + +scan: + uvx semgrep@1.167.0 scan --config .semgrep.yml --metrics=off --error . + # `git` (not `dir`): scan committed history like CI, catching secrets committed then deleted. + gitleaks git . --redact --exit-code 1 diff --git a/automation/mise.toml b/automation/mise.toml new file mode 100644 index 0000000..90ff90c --- /dev/null +++ b/automation/mise.toml @@ -0,0 +1,7 @@ +[tools] +python = "3.11" +uv = "0.13.4" +just = "1.50.0" +copier = "9.15.2" +# scanner-only tool; pinned here so Renovate's mise manager keeps it fresh. +gitleaks = "8.30.1" diff --git a/automation/pyproject.toml b/automation/pyproject.toml new file mode 100644 index 0000000..c554a80 --- /dev/null +++ b/automation/pyproject.toml @@ -0,0 +1,125 @@ +[project] +# PEP 503 distribution name: MUST be the validated slug, NOT the free-text +# project_name. "Demo Project" (spaces/caps) is not a valid name and `uv lock` +# — run by the copy-only _tasks on every render — aborts the whole generation. +name = "kickstarter_workflow" +version = "0.1.0" +# description / author_* are free text: escape backslash and double-quote so a value +# like `A "great" project` stays a valid TOML basic string — an unescaped " would break +# parsing and abort the copy-time `uv lock` (the package_name slug is already validated). +description = "Prepare and verify isolated Python development workspaces." +authors = [{ name = "Ashlen", email = "dev@anthes.is" }] +requires-python = ">=3.11" +readme = "README.md" +license = "ISC" +dependencies = [] + +[project.scripts] +kickstarter-workflow = "kickstarter_workflow.__main__:main" + +[build-system] +requires = ["uv_build>=0.11.23,<0.12"] +build-backend = "uv_build" + +[dependency-groups] +dev = [ + "copier==9.15.2", + "uv==0.13.4", + "ruff>=0.17,<0.18", + # recommended mode is version-coupled like ruff's ALL set: it tracks every basedpyright + # rule, so a new minor can turn a green tree red — cap it and bump deliberately. + "basedpyright>=1.39,<1.40", + "pytest>=8,<10", + "pytest-cov>=5,<8", + "pre-commit>=4,<5", + "hypothesis>=6", + "mutmut>=3.6", + "pip-audit>=2.10", +] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "E501", "ANN401", "PLR0913", "PLR2004", "TD002", "TD003", "FIX002", + "EM101", "EM102", "TRY003", "D100", "D203", "D213", "COM812", "ISC001", + "CPY001", +] + +# max-complexity governs C901 (mccabe), active only under select=ALL; the curated +# ruleset omits the C90 prefix, so this block would be dead config there (like pydocstyle). +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.ruff.lint.pylint] +max-args = 8 +max-branches = 12 +max-returns = 6 +max-statements = 50 + +# Only meaningful under select=ALL, which activates the pydocstyle (D) rules; the +# curated ruleset omits D, so this block would be dead config there. +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.isort] +known-first-party = ["kickstarter_workflow"] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101", "PLR2004", "ANN", "D", "INP001", "SLF001"] + +[tool.ruff.format] +quote-style = "double" +docstring-code-format = true + +[tool.basedpyright] +typeCheckingMode = "recommended" +pythonVersion = "3.11" +include = ["src", "tests"] +reportMissingImports = "error" + +[tool.pytest.ini_options] +minversion = "8.0" +testpaths = ["tests"] +markers = [ + "property: property-based Hypothesis tests (deselect with -m 'not property')", +] +addopts = [ + "-q", + "--strict-markers", + "--strict-config", + "--import-mode=importlib", + "--cov=kickstarter_workflow", + "--cov-branch", + "--cov-report=term-missing", + "--cov-report=xml", +] + +[tool.coverage.run] +branch = true +source = ["kickstarter_workflow"] + +[tool.coverage.paths] +source = ["src/kickstarter_workflow", "*/site-packages/kickstarter_workflow"] + +[tool.coverage.report] +fail_under = 85 +show_missing = true +exclude_also = [ + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", + "raise NotImplementedError", + "@(abc\\.)?abstractmethod", +] + +[tool.mutmut] +source_paths = ["src/kickstarter_workflow"] +pytest_add_cli_args_test_selection = ["tests/unit"] +# --no-cov (not `-p no:cov`): the cov plugin must stay loaded so the project's +# addopts `--cov` flags remain valid args; `-p no:cov` unloads it and pytest then +# rejects `--cov`, so mutmut silently leaves every mutant "not checked". +pytest_add_cli_args = ["--no-cov"] +mutate_only_covered_lines = false diff --git a/automation/src/kickstarter_workflow/__init__.py b/automation/src/kickstarter_workflow/__init__.py new file mode 100644 index 0000000..089bf3d --- /dev/null +++ b/automation/src/kickstarter_workflow/__init__.py @@ -0,0 +1,6 @@ +"""Prepare, verify and selectively return task-owned Python work.""" + +from kickstarter_workflow.records import Record, Value +from kickstarter_workflow.workflow import Workflow + +__all__ = ["Record", "Value", "Workflow"] diff --git a/automation/src/kickstarter_workflow/__main__.py b/automation/src/kickstarter_workflow/__main__.py new file mode 100644 index 0000000..d5364ec --- /dev/null +++ b/automation/src/kickstarter_workflow/__main__.py @@ -0,0 +1,39 @@ +"""JSON command-line interface for the public workflow.""" + +import argparse +import json +import sys +from dataclasses import dataclass +from pathlib import Path + +from kickstarter_workflow.records import Record, WorkflowError, load +from kickstarter_workflow.workflow import Workflow + + +@dataclass +class _Arguments(argparse.Namespace): + state: str = "" + operation: str = "" + request: str = "" + + +def main(argv: list[str] | None = None) -> int: + """Execute a request file and emit one JSON outcome.""" + parser = argparse.ArgumentParser(description=__doc__) + _ = parser.add_argument("--state", required=True) + _ = parser.add_argument( + "operation", choices=("prepare", "resume", "status", "verify", "return", "cleanup") + ) + _ = parser.add_argument("request") + arguments = parser.parse_args(argv, namespace=_Arguments()) + try: + request = load(Path(arguments.request).absolute()) + result = Workflow(Path(arguments.state).absolute()).dispatch(arguments.operation, request) + except (WorkflowError, OSError, ValueError) as error: + result: Record = {"status": "blocked", "reason": str(error)} + _ = sys.stdout.write(json.dumps(result, sort_keys=True) + "\n") + return 0 if result["status"] in {"prepared", "exempt", "verified", "returned", "cleaned"} else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/automation/src/kickstarter_workflow/environment.py b/automation/src/kickstarter_workflow/environment.py new file mode 100644 index 0000000..ae6bfa1 --- /dev/null +++ b/automation/src/kickstarter_workflow/environment.py @@ -0,0 +1,115 @@ +"""Small explicit PEP 621 environment adapter; no product runtime migration.""" + +import json +import re +import tomllib +from pathlib import Path +from typing import cast + +from kickstarter_workflow.files import inventory, mappings +from kickstarter_workflow.records import ( + Record, + Value, + WorkflowError, + digest, + no_links, + object_value, + text, +) + + +def environment_identity(workspace: Path) -> Record: + """Bind the prepared task-local environment and executed interpreter.""" + environment = no_links(workspace / ".venv") + interpreter = (environment / "bin/python").resolve(strict=True) + return { + "path": str(interpreter), + "sha256": digest(interpreter.read_bytes()), + "mode": interpreter.stat().st_mode & 0o777, + "configuration": digest(no_links(environment / "pyvenv.cfg").read_bytes()), + } + + +def _strings(value: Value, label: str) -> list[str]: + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise WorkflowError("unsupported environment list: " + label) + return cast("list[str]", value) + + +def _local_package(plan: Record, source: Path, metadata: Record, content: str) -> str: + build = object_value(metadata, "build-system") + if build.get("build-backend") != "uv_build" or set(build) != {"build-backend", "requires"}: + raise WorkflowError("only the static default uv_build package layout is supported") + _ = _strings(build.get("requires"), "build requirements") + mapped = {text(item, "source"): text(item, "target") for item in mappings(plan)} + if any(mapped.get(name) != "product/" + name for name in inventory(source)): + raise WorkflowError( + "packaged environment requires complete layout-preserving source/assets mapping" + ) + name = text(object_value(metadata, "project"), "name") + if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", name) is None: + raise WorkflowError("invalid product distribution name") + content = content.replace("dev = [\n", "dev = [\n " + json.dumps(name) + ",\n", 1) + return ( + content + + "\n[tool.uv.sources]\n" + + json.dumps(name) + + ' = { path = "product", editable = true }\n' + ) + + +def adapt_environment(plan: Record, source: Path, workspace: Path) -> None: + """Import static declared dependencies into development-only workbench setup.""" + if not (source / "pyproject.toml").exists(): + if "environment" in plan or any( + (source / name).exists() for name in ("requirements.txt", "setup.py", "setup.cfg") + ): + raise WorkflowError("environment mapping requires a static PEP 621 pyproject.toml") + return + selected = object_value(plan, "environment") + if selected.get("project") != "pyproject.toml" or set(selected) != { + "project", + "groups", + "extras", + }: + raise WorkflowError("environment requires project=pyproject.toml, groups and extras") + if not any( + item.get("source") == "pyproject.toml" and item.get("target") == "product/pyproject.toml" + for item in mappings(plan) + ): + raise WorkflowError("environment pyproject.toml must be mapped to product/pyproject.toml") + metadata = cast("Record", tomllib.loads((source / "pyproject.toml").read_text())) + project = object_value(metadata, "project") + tools = metadata.get("tool", {}) + if project.get("dynamic") or (isinstance(tools, dict) and "uv" in tools): + raise WorkflowError("unsupported dynamic metadata or tool.uv environment configuration") + dependencies = _strings(project.get("dependencies", []), "dependencies") + for group in _strings(selected["groups"], "groups"): + dependencies.extend(_strings(object_value(metadata, "dependency-groups").get(group), group)) + for extra in _strings(selected["extras"], "extras"): + dependencies.extend( + _strings(object_value(project, "optional-dependencies").get(extra), extra) + ) + config = workspace / "pyproject.toml" + content = re.sub( + r"^requires-python = .+$", + "requires-python = " + json.dumps(text(project, "requires-python")), + config.read_text(), + count=1, + flags=re.MULTILINE, + ) + content = content.replace( + "dev = [\n", + "dev = [\n" + "".join(" " + json.dumps(dep) + ",\n" for dep in dependencies), + 1, + ) + if "build-system" in metadata: + content = _local_package(plan, source, metadata, content) + content = content.replace('extraPaths = ["product"]', 'extraPaths = ["product", "product/src"]') + _ = config.write_text(content) + justfile = workspace / "justfile" + _ = justfile.write_text( + justfile.read_text() + .replace("ruff check", "ruff check --config pyproject.toml") + .replace("ruff format", "ruff format --config pyproject.toml") + ) diff --git a/automation/src/kickstarter_workflow/files.py b/automation/src/kickstarter_workflow/files.py new file mode 100644 index 0000000..7dc2d80 --- /dev/null +++ b/automation/src/kickstarter_workflow/files.py @@ -0,0 +1,135 @@ +"""Complete inventories and explicit source/test/context mappings.""" + +import os +import shutil +import stat +from pathlib import Path, PurePosixPath + +from kickstarter_workflow.records import Record, Value, WorkflowError, digest, no_links, text + +IGNORED = frozenset( + { + ".git", + ".venv", + "__pycache__", + ".ruff_cache", + ".pytest_cache", + ".hypothesis", + ".mypy_cache", + "htmlcov", + "mutants", + } +) +REPORTS = frozenset({".coverage", "coverage.xml", "coverage.json", "requirements-audit.txt"}) + + +def confined(root: Path, relative: str) -> Path: + """Resolve one canonical portable relative path without following links.""" + parsed = PurePosixPath(relative) + if ( + not relative + or parsed.is_absolute() + or str(parsed) != relative + or any(part in {"..", ".git", ".venv"} for part in parsed.parts) + or "\\" in relative + or any(ord(char) < 32 for char in relative) + ): + raise WorkflowError("unsafe relative path") + return no_links(root / relative) + + +def inventory(root: Path) -> Record: + """Fingerprint all relevant regular files and modes; reject special files.""" + _ = no_links(root) + result: Record = {} + for directory, directories, files in os.walk( + root, followlinks=False, onerror=_raise_scan_error + ): + current = Path(directory) + for name in [*directories, *files]: + if (current / name).is_symlink(): + raise WorkflowError("symlink in inventory") + directories[:] = sorted(name for name in directories if name not in IGNORED) + for name in sorted(files): + if name == ".git" or name in REPORTS or name.endswith((".pyc", ".pyo")): + continue + path = current / name + metadata = path.stat() + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise WorkflowError("inventory requires independent regular files") + result[path.relative_to(root).as_posix()] = { + "sha256": digest(path.read_bytes()), + "mode": stat.S_IMODE(metadata.st_mode), + } + return result + + +def _raise_scan_error(error: OSError) -> None: + raise error + + +def mappings(plan: Record) -> list[Record]: + """Validate nonoverlapping per-file mappings with explicit product roles.""" + value = plan.get("mapping") + if not isinstance(value, list) or not value: + raise WorkflowError("complete product mapping required") + result: list[Record] = [] + sources: set[str] = set() + targets: set[str] = set() + for item in value: + if not isinstance(item, dict): + raise WorkflowError("mapping entry must be an object") + source, target, role = (text(item, key) for key in ("source", "target", "role")) + if source in sources or target in targets or role not in {"source", "test", "context"}: + raise WorkflowError("duplicate mapping or invalid role") + sources.add(source) + targets.add(target) + result.append(item) + if not {"source", "test"}.issubset({text(item, "role") for item in result}): + raise WorkflowError("product source and maintained tests required") + return result + + +def copy_inventory(source: Path, destination: Path, files: Record) -> None: + """Copy an already inventoried tree without copying its Git/environment state.""" + for name in files: + target = confined(destination, name) + target.parent.mkdir(parents=True, exist_ok=True) + _ = shutil.copy2(confined(source, name), target) + + +def copy_mapping(plan: Record, source: Path, destination: Path) -> None: + """Place explicit product files below the generated workbench's product folder.""" + for item in mappings(plan): + target_name = text(item, "target") + if not target_name.startswith("product/"): + raise WorkflowError("workbench mappings must stay below product/") + target = confined(destination, target_name) + original = confined(source, text(item, "source")) + if item.get("new") is True: + if original.exists(): + raise WorkflowError("new mapping would overwrite an existing source file") + continue + target.parent.mkdir(parents=True, exist_ok=True) + _ = shutil.copy2(original, target) + + +def names(plan: Record, role: str, *, destination: bool = False) -> list[str]: + """Select measured source or test paths from the declared mapping.""" + return [ + text(item, "source" if destination else "target") + for item in mappings(plan) + if item["role"] == role + ] + + +def string_list(record: Record, key: str) -> list[str]: + """Validate a nonempty list of strings.""" + value: Value = record.get(key) + if ( + not isinstance(value, list) + or not value + or not all(isinstance(item, str) and item for item in value) + ): + raise WorkflowError(f"nonempty string list required: {key}") + return [str(item) for item in value] diff --git a/automation/src/kickstarter_workflow/git_context.py b/automation/src/kickstarter_workflow/git_context.py new file mode 100644 index 0000000..05900fe --- /dev/null +++ b/automation/src/kickstarter_workflow/git_context.py @@ -0,0 +1,145 @@ +"""Disposable Git discovery over the complete measured candidate.""" + +from pathlib import Path + +from kickstarter_workflow.files import inventory +from kickstarter_workflow.process import Runner +from kickstarter_workflow.records import ( + Record, + WorkflowError, + digest, + load, + object_value, + save, + text, +) + + +def _complete_history(root: Path, runner: Runner) -> None: + runner.env["GIT_NO_LAZY_FETCH"] = "1" + if runner.run(["git", "rev-parse", "--is-shallow-repository"], root) != "false": + raise WorkflowError("shallow Git history is unsupported; provide complete local history") + _ = runner.run( + ["git", "config", "--local", "--get", "extensions.partialClone"], root, allow_failure=True + ) + if runner.last_exit == 0: + raise WorkflowError("partial Git clones are unsupported") + common = Path( + runner.run(["git", "rev-parse", "--path-format=absolute", "--git-common-dir"], root) + ) + if (common / "info/grafts").exists(): + raise WorkflowError("rewritten Git history via grafts is unsupported") + + +def history(root: Path, runner: Runner) -> Record: + """Identify local reachable history and refs, never the caller's mutable index.""" + _complete_history(root, runner) + head = runner.run(["git", "rev-parse", "--verify", "--quiet", "HEAD"], root, allow_failure=True) + if runner.last_exit: + head = "" + branch = runner.run(["git", "symbolic-ref", "--quiet", "HEAD"], root, allow_failure=True) + if runner.last_exit: + branch = "" + refs: Record = {} + output = runner.run( + ["git", "for-each-ref", "--format=%(refname) %(objectname) %(symref)"], root + ) + for line in output.splitlines(): + name, oid, *symbolic = line.split() + if name.startswith("refs/replace/"): + raise WorkflowError("replacement Git objects are unsupported") + refs[name] = {"oid": oid, "symbolic": symbolic[0] if symbolic else ""} + return {"head": head, "branch": branch, "refs": refs} + + +def identity(root: Path, runner: Runner) -> Record: + """Bind semantic Git inputs without volatile index stat-cache bytes.""" + _ = runner.run(["git", "ls-files", "--stage", "-z"], root) + index = digest(runner.last_output.read_bytes()) + return {**history(root, runner), "index": index} + + +def retain(source: Path, task: Path, runner: Runner) -> str: + """Freeze complete reachable local history without cloning config or hooks.""" + before = history(source, runner) + bundle = task / "repository.bundle" + if before["head"] or before["refs"]: + revisions = ["--all", "HEAD"] if before["head"] else ["--all"] + _ = runner.run(["git", "bundle", "create", str(bundle), *revisions], source) + _ = runner.run(["git", "bundle", "verify", str(bundle)], source) + if history(source, runner) != before: + raise WorkflowError("source Git history changed during preparation") + record: Record = { + "history": before, + "bundle": digest(bundle.read_bytes()) if bundle.exists() else None, + } + path = task / "repository-git.json" + save(path, record) + return digest(path.read_bytes()) + + +def validate(state: Record, task: Path, runner: Runner) -> None: + """Refuse stale source refs and damaged retained Git context.""" + path = task / "repository-git.json" + if digest(path.read_bytes()) != state.get("git_sha256"): + raise WorkflowError("retained Git context changed") + record = load(path) + bundle = task / "repository.bundle" + if (digest(bundle.read_bytes()) if bundle.exists() else None) != record.get("bundle"): + raise WorkflowError("retained Git bundle changed") + plan = object_value(state, "plan") + if history(Path(text(plan, "source")), runner) != record["history"]: + raise WorkflowError("source Git history or refs changed; prepare a new task") + if ( + plan["mode"] == "existing" + and history(Path(text(state, "workspace")), runner) != record["history"] + ): + raise WorkflowError("workspace Git history or refs changed") + + +def restore(task: Path, root: Path, runner: Runner) -> None: + """Load retained objects and exact refs, leaving all worktree files untouched.""" + record = load(task / "repository-git.json") + expected = object_value(record, "history") + if record["bundle"] is not None: + _ = runner.run(["git", "bundle", "unbundle", str(task / "repository.bundle")], root) + refs = object_value(expected, "refs") + for name in refs: + _ = runner.run(["git", "update-ref", name, text(object_value(refs, name), "oid")], root) + for name in refs: + symbolic = object_value(refs, name)["symbolic"] + if isinstance(symbolic, str) and symbolic: + _ = runner.run(["git", "symbolic-ref", name, symbolic], root) + if expected["branch"]: + _ = runner.run(["git", "symbolic-ref", "HEAD", text(expected, "branch")], root) + elif expected["head"]: + _ = runner.run(["git", "update-ref", "--no-deref", "HEAD", text(expected, "head")], root) + if history(root, runner) != expected: + raise WorkflowError("repository Git context was not faithfully restored") + + +def stage_candidate(root: Path, runner: Runner) -> None: + """Enroll exact candidate bytes without filters, ignores or source-index writes.""" + files = inventory(root) + if not files: + raise WorkflowError("empty repository candidate") + _ = runner.run(["git", "read-tree", "--empty"], root) + for name in files: + blob = runner.run(["git", "hash-object", "-w", "--no-filters", "--", name], root) + mode = int(str(object_value(files, name)["mode"])) + _ = runner.run( + [ + "git", + "update-index", + "--add", + "--cacheinfo", + "100755" if mode & 0o111 else "100644", + blob, + name, + ], + root, + ) + _ = runner.run(["git", "ls-files", "-z"], root) + discovered = runner.last_output.read_text().split("\0")[:-1] + if set(discovered) != set(files): + raise WorkflowError("Git discovery differs from the measured candidate") diff --git a/automation/src/kickstarter_workflow/integrity.py b/automation/src/kickstarter_workflow/integrity.py new file mode 100644 index 0000000..92e5e21 --- /dev/null +++ b/automation/src/kickstarter_workflow/integrity.py @@ -0,0 +1,71 @@ +"""Validate retained preparation independently of mutable product edits.""" + +import os +from pathlib import Path + +from kickstarter_workflow.environment import environment_identity +from kickstarter_workflow.files import inventory +from kickstarter_workflow.git_context import validate +from kickstarter_workflow.process import Runner +from kickstarter_workflow.records import ( + Record, + WorkflowError, + digest, + load, + no_links, + object_value, + private_directory, + text, +) +from kickstarter_workflow.setup import git_root, template + + +def validate_preparation(state: Record, task: Path, runner: Runner) -> None: + """Refuse missing or modified provenance while preserving unresolved work.""" + plan = object_value(state, "plan") + workspace = Path(text(state, "workspace")) + source = no_links(Path(text(plan, "source"))) + git_root(runner, source) + for name in ("source", "scaffold"): + path = task / (name + ".json") + metadata = path.stat() + if ( + metadata.st_mode & 0o777 != 0o600 + or metadata.st_nlink != 1 + or metadata.st_uid != os.getuid() + ): + raise WorkflowError("retained preparation record must be private") + if digest(path.read_bytes()) != state.get(name + "_sha256"): + raise WorkflowError("stale preparation inventory: " + name) + if plan["mode"] != "new": + if not (task / "baseline").is_dir(): + raise WorkflowError("retained baseline is missing") + private_directory(task / "baseline") + if inventory(task / "baseline") != load(task / "source.json"): + raise WorkflowError("retained baseline is missing or changed") + git_root(runner, workspace) + validate(state, task, runner) + if environment_identity(workspace) != state.get("environment"): + raise WorkflowError("stale prepared environment") + _, revision = template(plan, runner) + if revision != state.get("template_revision") or runner.identities(task) != state.get("tools"): + raise WorkflowError("stale setup provenance") + baseline, current = load(task / "scaffold.json"), inventory(workspace) + protected = { + name + for name in baseline + if name + in { + "justfile", + "pyproject.toml", + ".copier-answers.yml", + "uv.lock", + ".python-version", + "product/pyproject.toml", + } + or name.startswith("tests/policy/") + } + if any(current.get(name) != baseline[name] for name in protected): + raise WorkflowError( + "stale prepared gate configuration changed or weakened; prepare a new plan" + ) diff --git a/automation/src/kickstarter_workflow/policy.py b/automation/src/kickstarter_workflow/policy.py new file mode 100644 index 0000000..f8b5adf --- /dev/null +++ b/automation/src/kickstarter_workflow/policy.py @@ -0,0 +1,53 @@ +"""Admission of generated gates; existing reviewed configuration is not a default.""" + +import re +import tomllib +from pathlib import Path +from typing import cast + +from kickstarter_workflow.files import string_list +from kickstarter_workflow.records import Record, WorkflowError, object_value + + +def coverage_floor(workspace: Path) -> float: + """Read the actual approved project threshold, including existing choices.""" + config = cast("Record", tomllib.loads((workspace / "pyproject.toml").read_text())) + report = object_value(object_value(object_value(config, "tool"), "coverage"), "report") + floor = float(str(report.get("fail_under", 0))) + if not 0 < floor <= 100: + raise WorkflowError("invalid project coverage threshold") + return floor + + +def gate_policy(workspace: Path, *, existing: bool) -> None: + """Validate CI membership without imposing new-project defaults on reuse.""" + config = cast("Record", tomllib.loads((workspace / "pyproject.toml").read_text())) + tools = object_value(config, "tool") + lint = object_value(object_value(tools, "ruff"), "lint") + types = object_value(tools, "basedpyright") + floor = coverage_floor(workspace) + required = {"fmt-check", "lint", "typecheck", "test"} + optional = { + "enable_property_tests": "fuzz", + "enable_policy_tests": "policy", + "enable_dependency_audit": "audit", + } + if existing: + answers = (workspace / ".copier-answers.yml").read_text() + for key, gate in optional.items(): + match = re.search(rf"^{key}: (true|false)$", answers, re.MULTILINE) + if match is None: + raise WorkflowError("missing generated gate provenance: " + key) + if match[1] == "true": + required.add(gate) + else: + required.update(optional.values()) + if ( + "ALL" not in string_list(lint, "select") + or types.get("typeCheckingMode") != "recommended" + or floor < 85 + ): + raise WorkflowError("mandatory strict template gates weakened") + match = re.search(r"^ci:\s*(.+)$", (workspace / "justfile").read_text(), re.MULTILINE) + if match is None or not required.issubset(match[1].split()): + raise WorkflowError("mandatory template CI membership missing") diff --git a/automation/src/kickstarter_workflow/process.py b/automation/src/kickstarter_workflow/process.py new file mode 100644 index 0000000..2f9a4cb --- /dev/null +++ b/automation/src/kickstarter_workflow/process.py @@ -0,0 +1,148 @@ +"""Controlled POSIX tool execution with durable raw results, never shell markers.""" + +import contextlib +import os +import shutil +import signal +import time +import uuid +from pathlib import Path + +from kickstarter_workflow.records import ( + Record, + Value, + WorkflowError, + digest, + no_links, + private_directory, + save, + text, +) + +PINS = {"copier": "copier 9.15.2", "uv": "uv 0.13.4"} + + +def _wait(pid: int, timeout: int) -> int: + deadline = time.monotonic() + timeout + try: + while time.monotonic() < deadline: + child, status = os.waitpid(pid, os.WNOHANG) + if child: + return os.waitstatus_to_exitcode(status) + time.sleep(0.02) + raise WorkflowError("tool timed out") + finally: + with contextlib.suppress(ProcessLookupError): + os.killpg(pid, signal.SIGKILL) + with contextlib.suppress(ChildProcessError): + _ = os.waitpid(pid, 0) + + +class Runner: + """Resolve an explicit toolbox and retain every invocation before executing it.""" + + def __init__(self, tools: Record, evidence: Path, cache: Path) -> None: + """Create an invocation directory and task-local cache environment.""" + private_directory(evidence) + self.evidence: Path = evidence + self.cache: Path = cache + self.last_exit: int = 0 + self.last_output: Path = evidence / "not-started" + self.tools: dict[str, str] = {} + for name in ("git", "copier", "uv", "just"): + path = Path(text(tools, name)) + if not path.is_absolute() or not path.is_file() or not os.access(path, os.X_OK): + raise WorkflowError(f"missing executable tool: {name}") + self.tools[name] = str(path.resolve()) + self.env: dict[str, str] = { + "HOME": str(Path.home()), + "PATH": os.pathsep.join( + dict.fromkeys( + [*(str(Path(p).parent) for p in self.tools.values()), "/usr/bin", "/bin"] + ) + ), + "TMPDIR": os.environ.get("TMPDIR", str(cache / "tmp")), + "UV_CACHE_DIR": str(cache / "uv"), + "UV_PYTHON_INSTALL_DIR": str(cache / "python"), + "UV_NO_CONFIG": "1", + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "core.hooksPath", + "GIT_CONFIG_VALUE_0": os.devnull, + "PYTHONHASHSEED": "0", + } + + def run( + self, args: list[str], cwd: Path, *, timeout: int = 600, allow_failure: bool = False + ) -> str: + """Run an allowlisted executable, capturing status and unmodified output.""" + if not args or args[0] not in self.tools: + raise WorkflowError("command executable is not in the approved toolbox") + _ = no_links(cwd) + executable = self.tools[args[0]] + token = uuid.uuid4().hex + record = self.evidence / f"{token}.json" + output = self.evidence / f"{token}.log" + self.last_output = output + command: list[Value] = [executable, *args[1:]] + result: Record = { + "argv": command, + "cwd": str(cwd), + "status": "running", + "output": str(output), + } + save(record, result) + actions = [ + (os.POSIX_SPAWN_OPEN, 1, str(output), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), + (os.POSIX_SPAWN_DUP2, 1, 2), + ] + previous = Path.cwd() + try: + os.chdir(cwd) + pid = os.posix_spawn( + executable, [executable, *args[1:]], self.env, file_actions=actions, setpgroup=0 + ) + finally: + os.chdir(previous) + exit_code = _wait(pid, timeout) + self.last_exit = exit_code + result.update( + { + "status": "passed" if exit_code == 0 else "failed", + "exit": exit_code, + "output_sha256": digest(output.read_bytes()), + } + ) + save(record, result) + content = output.read_text(errors="replace") + if exit_code and not allow_failure: + raise WorkflowError(f"{args[0]} failed ({exit_code}); raw result: {record}") + return content.strip() + + def child(self) -> "Runner": + """Create a separate immutable evidence namespace for a verification phase.""" + return Runner( + dict(self.tools), + self.evidence / uuid.uuid4().hex, + self.cache, + ) + + def identities(self, cwd: Path) -> Record: + """Bind executable bytes and versions, enforcing the reviewed tool pins.""" + identities: Record = {} + for name, path in self.tools.items(): + version = self.run([name, "--version"], cwd) + if name in PINS and not (version == PINS[name] or version.startswith(PINS[name] + " ")): + raise WorkflowError(f"unapproved {name} version") + identities[name] = { + "path": path, + "sha256": digest(Path(path).read_bytes()), + "version": version, + } + return identities + + +def default_tools() -> Record: + """Discover explicit executable paths for a plan, without installing anything.""" + return {name: shutil.which(name) for name in ("git", "copier", "uv", "just")} diff --git a/automation/src/kickstarter_workflow/py.typed b/automation/src/kickstarter_workflow/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/automation/src/kickstarter_workflow/records.py b/automation/src/kickstarter_workflow/records.py new file mode 100644 index 0000000..831ca40 --- /dev/null +++ b/automation/src/kickstarter_workflow/records.py @@ -0,0 +1,113 @@ +"""Private durable records and confined file inventories.""" + +import fcntl +import hashlib +import json +import os +import re +import stat +from pathlib import Path +from typing import TypeAlias, cast + +Value: TypeAlias = str | int | float | bool | list["Value"] | dict[str, "Value"] | None +Record: TypeAlias = dict[str, Value] + + +class WorkflowError(Exception): + """A precondition failed; preserve work for an explicit decision.""" + + +def text(record: Record, key: str) -> str: + """Read a required nonempty string without coercion.""" + value = record.get(key) + if not isinstance(value, str) or not value: + raise WorkflowError(f"missing or invalid {key}") + return value + + +def object_value(record: Record, key: str) -> Record: + """Read a required JSON object.""" + value = record.get(key) + if not isinstance(value, dict): + raise WorkflowError(f"missing or invalid {key}") + return value + + +def identifier(record: Record, key: str) -> str: + """Validate stable identifiers without silently normalizing them.""" + value = text(record, key) + if not re.fullmatch(r"[a-z][a-z0-9_-]{0,63}", value): + raise WorkflowError(f"invalid {key} identifier") + return value + + +def no_links(path: Path) -> Path: + """Reject symlink traversal, including any existing ancestor.""" + if not path.is_absolute() or ".." in path.parts: + raise WorkflowError("absolute confined paths required") + for part in (path, *path.parents): + if part.is_symlink(): + raise WorkflowError("symlink traversal refused") + return path + + +def private_directory(path: Path) -> None: + """Create or validate a user-owned, non-shared state directory.""" + _ = no_links(path) + path.mkdir(mode=0o700, parents=True, exist_ok=True) + metadata = path.stat() + if metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) != 0o700: + raise WorkflowError("state directory must be owned by this user with mode 0700") + + +def digest(data: bytes) -> str: + """Fingerprint content using SHA-256.""" + return hashlib.sha256(data).hexdigest() + + +def encoded(record: Record) -> bytes: + """Encode deterministic records suitable for content binding.""" + return (json.dumps(record, sort_keys=True, indent=2) + "\n").encode() + + +def load(path: Path) -> Record: + """Read an object record without accepting links or malformed data.""" + _ = no_links(path) + value = cast("Value", json.loads(path.read_bytes())) + if not isinstance(value, dict): + raise WorkflowError("record must be an object") + return value + + +def sync_directory(path: Path) -> None: + """Persist directory entries on supported POSIX filesystems.""" + descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def save(path: Path, record: Record) -> None: + """Atomically publish a private record and sync its immediate parent.""" + _ = no_links(path) + temporary = path.with_suffix(".pending") + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "wb") as stream: + _ = stream.write(encoded(record)) + stream.flush() + os.fsync(stream.fileno()) + _ = temporary.replace(path) + sync_directory(path.parent) + + +def project_record(project: Path, record: Record | None = None) -> None: + """Serialize only shared metadata, preserving interrupted writes for recovery.""" + path = no_links(project / "project.lock") + descriptor = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, "w"): + fcntl.flock(descriptor, fcntl.LOCK_EX) + if (project / "project.pending").exists(): + raise WorkflowError("pending project write requires explicit recovery; work preserved") + if record is not None: + save(project / "project.json", record) diff --git a/automation/src/kickstarter_workflow/setup.py b/automation/src/kickstarter_workflow/setup.py new file mode 100644 index 0000000..78cc424 --- /dev/null +++ b/automation/src/kickstarter_workflow/setup.py @@ -0,0 +1,238 @@ +"""Pinned template preparation, separate from product verification.""" + +import json +import re +import shlex +from pathlib import Path + +from kickstarter_workflow.environment import adapt_environment, environment_identity +from kickstarter_workflow.files import confined, copy_inventory, copy_mapping, inventory, mappings +from kickstarter_workflow.git_context import restore, retain +from kickstarter_workflow.policy import gate_policy +from kickstarter_workflow.process import Runner +from kickstarter_workflow.records import ( + Record, + WorkflowError, + digest, + no_links, + object_value, + save, + text, +) + +CANONICAL = "https://github.com/maybebyte/python-kickstarter.git" +APPROVED = frozenset({"37adbeeea53a242ed25db602a35b9d719d95c8ae"}) +ANSWER_KEYS = frozenset( + { + "project_name", + "package_name", + "author_name", + "author_email", + "description", + "license", + "python_version", + "project_type", + } +) + + +def git_root(runner: Runner, root: Path) -> None: + """Require this exact repository, never an enclosing home-wide ancestor.""" + actual = runner.run(["git", "rev-parse", "--show-toplevel"], root) + if Path(actual).resolve() != root.resolve(): + raise WorkflowError("source must be the exact Git root") + + +def template(plan: Record, runner: Runner) -> tuple[Path, str]: + """Validate a clean canonical cache at an explicitly reviewed full commit.""" + selected = object_value(plan, "template") + revision = text(selected, "revision") + if re.fullmatch(r"[0-9a-f]{40}", revision) is None: + raise WorkflowError("template revision must be an exact lowercase 40-hex commit") + if revision not in APPROVED or text(selected, "source") != CANONICAL: + raise WorkflowError("template source/revision has not been approved") + cache = no_links(Path(text(selected, "cache"))) + git_root(runner, cache) + _ = runner.run(["git", "symbolic-ref", "--quiet", "HEAD"], cache, allow_failure=True) + if runner.last_exit == 0: + raise WorkflowError("moving template checkout; detach the approved commit") + if runner.run(["git", "rev-parse", "HEAD"], cache) != revision: + raise WorkflowError("template cache is not at the approved commit") + if runner.run(["git", "status", "--porcelain", "--untracked-files=all"], cache): + raise WorkflowError("dirty template source") + if runner.run(["git", "remote", "get-url", "origin"], cache) != CANONICAL: + raise WorkflowError("noncanonical template cache") + config = (cache / "copier.yml").read_text() + if re.search(r"^_(?:jinja_extensions|migrations|external_data)\s*:", config, re.MULTILINE): + raise WorkflowError("unsafe executable template features") + return cache, revision + + +def _answers(plan: Record) -> list[str]: + answers = object_value(plan, "answers") + if frozenset(answers) != ANSWER_KEYS: + raise WorkflowError("explicit identity, compatibility and app/library answers required") + args: list[str] = [] + for key in sorted(answers): + args.extend(["--data", f"{key}={text(answers, key)}"]) + return args + + +def _preflight(plan: Record, source: Path, workspace: Path) -> None: + _ = text(plan, "scope_review") + requirements = plan.get("git_requirements", ["index", "history", "refs"]) + if not isinstance(requirements, list) or any( + not isinstance(item, str) or item not in {"index", "history", "refs"} + for item in requirements + ): + raise WorkflowError( + "unsupported Git check requirements; only candidate index, local history and refs are provided" + ) + if not isinstance(plan.get("repository_checks"), list) or not plan["repository_checks"]: + raise WorkflowError("explicit mandatory repository check plan required") + if workspace.is_relative_to(source) or source.is_relative_to(workspace.parent): + raise WorkflowError("source, task records and workspace must be separate") + for item in mappings(plan): + _ = confined(source, text(item, "source")) + target = text(item, "target") + _ = confined(workspace, target) + # just interpolation, Ruff globs and pytest selectors are not literal paths. + if item["role"] == "test" and any( + "::" in name or any(char in name for char in "{}[]*?") + for name in (text(item, "source"), target) + ): + raise WorkflowError("unsupported recipe/glob/test-selector syntax in mapped test path") + + +def _generate(plan: Record, workspace: Path, cache: Path, revision: str, runner: Runner) -> None: + _ = runner.run( + [ + "copier", + "copy", + "--defaults", + "--skip-tasks", + "--vcs-ref", + revision, + "--data", + "enable_precommit_install=false", + "--data", + "in_existing_repo=false", + *_answers(plan), + str(cache), + str(workspace), + ], + workspace.parent, + ) + _ = runner.run( + ["git", "-c", "init.templateDir=", "init", "--quiet", str(workspace)], workspace.parent + ) + git_root(runner, workspace) + + +def _reuse(source: Path, workspace: Path, cache: Path, revision: str, runner: Runner) -> None: + answers = (source / ".copier-answers.yml").read_text() + origin = re.search(r"^_src_path: (.+)$", answers, re.MULTILINE) + commit = re.search(r"^_commit: ([a-zA-Z0-9.\-]+)$", answers, re.MULTILINE) + if origin is None or origin[1] not in {CANONICAL, str(cache)} or commit is None: + raise WorkflowError("existing project lacks canonical Copier provenance") + if runner.run(["git", "rev-parse", "--verify", commit[1] + "^{commit}"], cache) != revision: + raise WorkflowError("existing project revision differs; upgrades are separate") + workspace.mkdir(mode=0o700) + copy_inventory(source, workspace, inventory(source)) + _ = runner.run( + ["git", "-c", "init.templateDir=", "init", "--quiet", str(workspace)], workspace.parent + ) + git_root(runner, workspace) + + +def _adapt_workbench(plan: Record, workspace: Path) -> None: + package = text(object_value(plan, "answers"), "package_name") + config_path = workspace / "pyproject.toml" + config = config_path.read_text() + config = config.replace( + 'include = ["src", "tests"]', + 'include = ["src", "tests", "product"]\nextraPaths = ["product"]', + ) + config = config.replace(f'source = ["{package}"]', f'source = ["{package}", "product"]') + test_ignores = '["S101", "PLR2004", "ANN", "D", "INP001", "SLF001"]' + additions = "".join( + f"{json.dumps(text(item, 'target'))} = {test_ignores}\n" + for item in mappings(plan) + if item["role"] == "test" + ) + config = config.replace( + "[tool.ruff.lint.per-file-ignores]\n", "[tool.ruff.lint.per-file-ignores]\n" + additions + ) + _ = config_path.write_text(config) + (workspace / "product").mkdir(exist_ok=True) + if not any(item["target"] == "product/__init__.py" for item in mappings(plan)): + _ = (workspace / "product/__init__.py").write_text( + '"""External product workspace, not a return payload."""\n' + ) + justfile = workspace / "justfile" + tests = " ".join( + shlex.quote(text(item, "target")) for item in mappings(plan) if item["role"] == "test" + ) + _ = justfile.write_text( + justfile.read_text().replace("tests/unit\n", "tests/unit " + tests + "\n") + ) + + +def prepare(plan: Record, task: Path, runner: Runner) -> Record: + """Prepare a new project, reuse an existing one or generate a workbench.""" + source = no_links(Path(text(plan, "source"))) + mode = text(plan, "mode") + if mode not in {"new", "existing", "workbench"}: + raise WorkflowError("unknown preparation mode") + workspace = source if mode == "new" else task / "workspace" + _preflight(plan, source, task / "workspace") + cache, revision = template(plan, runner) + identities = runner.identities(task) + if mode == "new": + if source.exists(): + raise WorkflowError("new project destination already exists") + _ = runner.run(["git", "rev-parse", "--show-toplevel"], source.parent, allow_failure=True) + if runner.last_exit == 0 and plan.get("isolate_git") is not True: + raise WorkflowError( + "new project has an enclosing Git root; explicit isolate_git authorization required" + ) + _generate(plan, workspace, cache, revision, runner) + before: Record = {} + else: + git_root(runner, source) + before = inventory(source) + snapshot = task / "baseline" + snapshot.mkdir(mode=0o700) + copy_inventory(source, snapshot, before) + if mode == "existing": + _reuse(source, workspace, cache, revision, runner) + else: + if (source / ".copier-answers.yml").exists(): + raise WorkflowError("generated project must use existing mode") + _generate(plan, workspace, cache, revision, runner) + copy_mapping(plan, source, workspace) + _adapt_workbench(plan, workspace) + adapt_environment(plan, source, workspace) + if inventory(source) != before: + raise WorkflowError("source changed during preparation") + gate_policy(workspace, existing=mode == "existing") + git_sha256 = retain(source, task, runner) + if mode == "existing": + restore(task, workspace, runner) + _ = runner.run( + ["uv", "sync", "--locked"] if (workspace / "uv.lock").exists() else ["uv", "sync"], + workspace, + ) + save(task / "source.json", before) + save(task / "scaffold.json", inventory(workspace)) + return { + "status": "prepared", + "plan": plan, + "workspace": str(workspace), + "template_revision": revision, + "tools": identities, + "environment": environment_identity(workspace), + "git_sha256": git_sha256, + "source_sha256": digest((task / "source.json").read_bytes()), + "scaffold_sha256": digest((task / "scaffold.json").read_bytes()), + } diff --git a/automation/src/kickstarter_workflow/transfer.py b/automation/src/kickstarter_workflow/transfer.py new file mode 100644 index 0000000..16664f8 --- /dev/null +++ b/automation/src/kickstarter_workflow/transfer.py @@ -0,0 +1,332 @@ +"""Reviewed selective return with three-way reconciliation and a recovery journal.""" + +import difflib +import os +import shutil +import uuid +from pathlib import Path + +from kickstarter_workflow.files import confined, copy_inventory, inventory, mappings, string_list +from kickstarter_workflow.process import Runner +from kickstarter_workflow.records import ( + Record, + Value, + WorkflowError, + digest, + load, + object_value, + save, + sync_directory, + text, +) +from kickstarter_workflow.setup import git_root +from kickstarter_workflow.verification import ( + product_evidence, + repository_checks, + validate_receipt, + verify, +) + +SCAFFOLD = frozenset( + { + "pyproject.toml", + "uv.lock", + "justfile", + "AGENTS.md", + ".copier-answers.yml", + ".pre-commit-config.yaml", + "coverage.xml", + } +) + + +def _review(state: Record, review: Record) -> list[str]: + _ = text(review, "reviewer") + _ = text(review, "reason") + if review.get("receipt") != state.get("receipt"): + raise WorkflowError("review does not bind the verified candidate") + allowlist = string_list(review, "allowlist") + permitted = {text(item, "source") for item in mappings(object_value(state, "plan"))} + if len(allowlist) != len(set(allowlist)) or not set(allowlist).issubset(permitted): + raise WorkflowError("return requires a unique explicit mapped product allowlist") + if any(name in SCAFFOLD or name.startswith((".git", ".github", ".venv")) for name in allowlist): + raise WorkflowError("scaffolding adoption requires a separate assignment") + return allowlist + + +def _merge(item: Record, state: Record, task: Path, runner: Runner) -> bytes: + plan = object_value(state, "plan") + source = confined(Path(text(plan, "source")), text(item, "source")) + baseline = confined(task / "baseline", text(item, "source")) + candidate = confined(Path(text(state, "workspace")), text(item, "target")) + if ( + candidate.stat().st_mode != baseline.stat().st_mode + and source.stat().st_mode != baseline.stat().st_mode + and candidate.stat().st_mode != source.stat().st_mode + ): + raise WorkflowError("conflicting destination mode change requires a decision") + if source.read_bytes() == baseline.read_bytes(): + return candidate.read_bytes() + if candidate.read_bytes() == baseline.read_bytes(): + return source.read_bytes() + _ = runner.run( + ["git", "merge-file", "-p", str(candidate), str(baseline), str(source)], + task, + allow_failure=True, + ) + if runner.last_exit: + raise WorkflowError("destination conflict requires a decision; no files transferred") + return runner.last_output.read_bytes() + + +def _reconcile(state: Record, task: Path, runner: Runner) -> tuple[Record, bool]: + plan = object_value(state, "plan") + source = Path(text(plan, "source")) + before = load(task / "source.json") + current = inventory(source) + if current == before: + return state, False + merged: list[tuple[Path, bytes, int]] = [] + workspace = Path(text(state, "workspace")) + candidate_inventory = inventory(workspace) + for item in mappings(plan): + name, target = text(item, "source"), text(item, "target") + if current.get(name) == before.get(name): + continue + if name not in before or name not in current: + raise WorkflowError("destination add/delete conflict requires a decision") + data = _merge(item, state, task, runner) + mode = object_value(candidate_inventory, target)["mode"] + if mode == object_value(before, name)["mode"]: + mode = object_value(current, name)["mode"] + merged.append((confined(workspace, target), data, int(str(mode)))) + diff = _candidate_diff(merged) + for path, data, mode in merged: + _ = path.write_bytes(data) + path.chmod(mode) + _ = (task / "baseline").rename(task / ("baseline-" + uuid.uuid4().hex)) + (task / "baseline").mkdir(mode=0o700) + copy_inventory(source, task / "baseline", current) + save(task / "source.json", current) + state = {**state, "source_sha256": digest((task / "source.json").read_bytes())} + updated = verify(state, task, runner.child()) + if inventory(workspace) != candidate_inventory: + report = task / ("reconciliation-" + text(updated, "receipt") + ".diff") + _ = report.write_text(diff) + updated.update( + { + "status": "review_required", + "reason": "reconciled candidate requires receipt-bound host intent review before return", + "reconciliation_diff": str(report), + "previous_receipt": state.get("receipt"), + "reconciled": True, + } + ) + save(task / "task.json", updated) + return updated, True + + +def _candidate_diff(merged: list[tuple[Path, bytes, int]]) -> str: + lines: list[str] = [] + for path, data, mode in merged: + lines.append(f"{path}: mode {path.stat().st_mode & 0o777:o} -> {mode:o}\n") + lines.extend( + difflib.unified_diff( + path.read_text(errors="replace").splitlines(keepends=True), + data.decode(errors="replace").splitlines(keepends=True), + fromfile="reviewed/" + path.name, + tofile="reconciled/" + path.name, + ) + ) + return "".join(lines) + + +def _journal( + state: Record, review: Record, allowlist: list[str], task: Path, *, reconciled: bool +) -> Record: + plan = object_value(state, "plan") + source, workspace = Path(text(plan, "source")), Path(text(state, "workspace")) + current, candidate = inventory(source), inventory(workspace) + entries: Record = {} + for item in mappings(plan): + name, target = text(item, "source"), text(item, "target") + if current.get(name) != candidate.get(target) and name not in allowlist: + raise WorkflowError("changed mapped files are missing from the reviewed allowlist") + if name in allowlist: + entries[name] = { + "before": current.get(name), + "after": candidate.get(target), + "target": target, + } + journal: Record = { + "status": "transferring", + "entries": entries, + "destination_before": current, + "review": review, + "state": state, + "reconciled": reconciled, + "applied": [], + } + save(task / "return.json", journal) + return journal + + +def _replace(candidate: Path, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.parent / (".kickstarter-return-" + uuid.uuid4().hex) + descriptor = os.open( + temporary, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + candidate.stat().st_mode & 0o777, + ) + with os.fdopen(descriptor, "wb") as stream: + _ = stream.write(candidate.read_bytes()) + stream.flush() + os.fsync(stream.fileno()) + temporary.chmod(candidate.stat().st_mode & 0o777) + _ = temporary.replace(destination) + sync_directory(destination.parent) + + +def effects(journal: Record) -> list[str]: + """Report actual applied paths even after interruption before a journal update.""" + state = object_value(journal, "state") + source = Path(text(object_value(state, "plan"), "source")) + current = inventory(source) + return [ + name + for name, entry in object_value(journal, "entries").items() + if isinstance(entry, dict) and current.get(name) == entry.get("after") + ] + + +def _apply(journal: Record, task: Path) -> None: + state = object_value(journal, "state") + source = Path(text(object_value(state, "plan"), "source")) + workspace = Path(text(state, "workspace")) + entries = object_value(journal, "entries") + current = inventory(source) + for name in entries: + entry = object_value(entries, name) + if current.get(name) not in (entry.get("before"), entry.get("after")): + raise WorkflowError("destination changed during interrupted return; decision required") + for name in entries: + entry = object_value(entries, name) + fresh = inventory(source).get(name) + if fresh == entry.get("after"): + continue + if fresh != entry.get("before"): + raise WorkflowError("destination changed immediately before transfer") + _replace(confined(workspace, text(entry, "target")), confined(source, name)) + journal["applied"] = list(effects(journal)) + save(task / "return.json", journal) + + +def return_product(state: Record, request: Record, task: Path, runner: Runner) -> Record: + """Return reviewed files, or recover an interrupted transfer without blind writes.""" + plan = object_value(state, "plan") + if plan["mode"] == "new": + raise WorkflowError("new projects already occupy their destination; no copy-back operation") + review = object_value(request, "review") + path = task / "return.json" + if path.exists(): + journal = load(path) + if journal.get("review") != review: + raise WorkflowError("interrupted return review differs; explicit decision required") + state = object_value(journal, "state") + _ = validate_receipt(state, task, runner) + else: + if state["status"] not in {"verified", "review_required"}: + raise WorkflowError("return requires successful current verification") + allowlist = _review(state, review) + _ = validate_receipt(state, task, runner) + git_root(runner, Path(text(plan, "source"))) + previous_receipt = state.get("receipt") + state, reconciled = _reconcile(state, task, runner) + if state["status"] == "review_required" and state.get("receipt") != previous_receipt: + return state + journal = _journal( + state, + review, + allowlist, + task, + reconciled=reconciled or state.get("reconciled") is True, + ) + save(task / "task.json", {**state, "status": "returning"}) + _apply(journal, task) + return _validate_destination(journal, task, runner) + + +def _require_inventory(actual: Record, expected: Record) -> None: + if actual != expected: + raise WorkflowError("destination differs from reviewed payload or unrelated files") + + +def _validate_destination(journal: Record, task: Path, runner: Runner) -> Record: + state = object_value(journal, "state") + plan = object_value(state, "plan") + source, workspace = Path(text(plan, "source")), Path(text(state, "workspace")) + before = inventory(source) + transferred = effects(journal) + expected = dict(object_value(journal, "destination_before")) + entries = object_value(journal, "entries") + expected.update({name: object_value(entries, name)["after"] for name in entries}) + journal["destination_before_checks"] = before + journal["transferred"] = list(transferred) + save(task / "return.json", journal) + try: + _require_inventory(before, expected) + repository_checks(plan, source, workspace, runner) + measured = product_evidence(plan, source, workspace, runner, destination=True) + _require_inventory(inventory(source), before) + _ = validate_receipt(state, task, runner) + except (WorkflowError, OSError, ValueError) as error: + current = inventory(source) + changes: list[Value] = [ + name + for name in sorted(before.keys() | current.keys()) + if before.get(name) != current.get(name) + ] + result: Record = { + **state, + "status": "blocked", + "reason": str(error), + "destination_validated": False, + "transferred": list(transferred), + "applied": list(effects(journal)), + "destination_changes": changes, + } + journal.update(result) + journal["destination_before_checks"] = before + journal["destination"] = current + journal["destination_evidence"] = str(runner.evidence) + save(task / "return.json", journal) + return result + journal.update( + { + "status": "returned", + "applied": list(effects(journal)), + "destination": inventory(source), + "destination_evidence": str(runner.evidence), + } + ) + save(task / "return.json", journal) + return { + **state, + **measured, + "status": "returned", + "destination_validated": True, + "reconciled": journal["reconciled"], + "applied": journal["applied"], + } + + +def cleanup(state: Record, task: Path) -> Record: + """Remove resolved task workspace only; retain records and raw diagnostics.""" + if state["status"] not in {"returned", "exempt", "cleaned"}: + raise WorkflowError("unresolved work must be preserved") + workspace = task / "workspace" + if workspace.exists(): + _ = inventory(workspace) + shutil.rmtree(workspace) + return {**state, "status": "cleaned"} diff --git a/automation/src/kickstarter_workflow/verification.py b/automation/src/kickstarter_workflow/verification.py new file mode 100644 index 0000000..3bfad6d --- /dev/null +++ b/automation/src/kickstarter_workflow/verification.py @@ -0,0 +1,225 @@ +"""Substantive execution evidence bound to candidate, configuration and tools.""" + +import re +import uuid +from pathlib import Path + +from kickstarter_workflow.environment import environment_identity +from kickstarter_workflow.files import ( + confined, + copy_inventory, + inventory, + mappings, + names, + string_list, +) +from kickstarter_workflow.git_context import identity, restore, stage_candidate +from kickstarter_workflow.policy import coverage_floor, gate_policy +from kickstarter_workflow.process import Runner +from kickstarter_workflow.records import ( + Record, + WorkflowError, + digest, + encoded, + load, + object_value, + private_directory, + save, + text, +) +from kickstarter_workflow.setup import git_root + + +def binding(state: Record, runner: Runner) -> Record: + """Bind all candidate bytes/modes, immutable plan, and actual tool identities.""" + workspace = Path(text(state, "workspace")) + return { + "interpreter": environment_identity(workspace), + "candidate": inventory(workspace), + "plan": object_value(state, "plan"), + "tools": runner.identities(workspace), + "git": identity(workspace, runner), + "source_git": identity(Path(text(object_value(state, "plan"), "source")), runner), + } + + +def validate_receipt(state: Record, task: Path, runner: Runner) -> Record: + """Refuse changed inputs, missing logs and interrupted or failed receipts.""" + token = text(state, "receipt") + receipt = load(confined(task, "receipts/" + token + ".json")) + if receipt.get("status") != "passed" or digest(encoded(receipt)) != token: + raise WorkflowError("failed or modified verification receipt") + if receipt.get("binding") != binding(state, runner): + raise WorkflowError("stale verification: candidate/configuration/tool identity changed") + for name, expected in object_value(receipt, "artifacts").items(): + path = confined(task, name) + if not path.is_file() or digest(path.read_bytes()) != expected: + raise WorkflowError("missing or modified verification evidence") + return receipt + + +def _repository(plan: Record, task: Path, workspace: Path, runner: Runner) -> Path: + if plan["mode"] != "workbench": + return workspace + root = task / ("repository-" + uuid.uuid4().hex) + root.mkdir(mode=0o700) + copy_inventory(task / "baseline", root, load(task / "source.json")) + for item in mappings(plan): + target = confined(root, text(item, "source")) + target.parent.mkdir(parents=True, exist_ok=True) + candidate = confined(workspace, text(item, "target")) + _ = target.write_bytes(candidate.read_bytes()) + target.chmod(candidate.stat().st_mode & 0o777) + _ = runner.run(["git", "-c", "init.templateDir=", "init", "--quiet", str(root)], task) + restore(task, root, runner) + stage_candidate(root, runner) + return root + + +def repository_checks(plan: Record, root: Path, workspace: Path, runner: Runner) -> None: + """Run declared checks plus the repository's non-optional CI entrypoint.""" + git_root(runner, root) + checks = plan.get("repository_checks") + if not isinstance(checks, list) or not checks: + raise WorkflowError("missing mandatory repository checks") + commands: list[list[str]] = [] + for check in checks: + command = string_list({"argv": check}, "argv") + commands.append([arg.replace("{workspace}", str(workspace)) for arg in command]) + if (root / "justfile").exists() and ["just", "ci"] not in commands: + commands.insert(0, ["just", "ci"]) + policy = root / ".kickstarter-checks.json" + if policy.exists(): + required = load(policy).get("checks") + if not isinstance(required, list): + raise WorkflowError("invalid repository check policy") + commands.extend(string_list({"argv": check}, "argv") for check in required) + runner.env["PYTHONPATH"] = os_path(root, names(plan, "source", destination=True)) + before: Record = {"files": inventory(root), "git": identity(root, runner)} + save(runner.evidence / ("repository-inputs-" + uuid.uuid4().hex + ".json"), before) + for command in commands: + _ = runner.run(command, root) + if {"files": inventory(root), "git": identity(root, runner)} != before: + raise WorkflowError("repository inputs changed while checks were running") + + +def os_path(root: Path, files: list[str]) -> str: + """Build an import path scoped to the concrete execution layout.""" + return ":".join( + dict.fromkeys( + [ + str(root), + *( + str(parent) + for name in files + for parent in confined(root, name).parents + if parent.is_relative_to(root) + ), + ] + ) + ) + + +def product_evidence( + plan: Record, root: Path, workspace: Path, runner: Runner, *, destination: bool = False +) -> Record: + """Execute declared maintained tests and inspect real branch/line coverage.""" + sources = names(plan, "source", destination=destination) + tests = names(plan, "test", destination=destination) + for name in [*sources, *tests]: + if not confined(root, name).is_file(): + raise WorkflowError("empty or missing product target") + output = runner.evidence / "product-coverage.json" + junit = runner.evidence / "product-tests.xml" + runner.env["PYTHONPATH"] = os_path(root, sources) + targets = sorted({str(Path(name).parent) for name in sources}) + _ = runner.run( + [ + "uv", + "run", + "--project", + str(workspace), + "--locked", + "pytest", + "--override-ini=addopts=", + "--cov-branch", + "--cov-report=json:" + str(output), + "--junitxml=" + str(junit), + *("--cov=" + target for target in targets), + *tests, + ], + root, + ) + coverage = object_value(load(output), "files") + for source in sources: + measured = object_value(coverage, source) + summary = object_value(measured, "summary") + if not measured.get("executed_lines") or float( + str(summary.get("percent_covered", 0)) + ) < coverage_floor(workspace): + raise WorkflowError("empty or inadequate real product coverage") + counts = re.findall(r")", junit.read_text()): + raise WorkflowError("no successful substantive product test execution") + minimum = min( + float(str(object_value(object_value(coverage, source), "summary")["percent_covered"])) + for source in sources + ) + return {"product_tests": len(counts), "product_coverage": int(minimum)} + + +def verify(state: Record, task: Path, runner: Runner) -> Record: + """Run all mandatory gates and publish a candidate-bound receipt only on success.""" + plan = object_value(state, "plan") + workspace = Path(text(state, "workspace")) + if plan["mode"] != "new" and inventory(Path(text(plan, "source"))) != load( + task / "source.json" + ): + raise WorkflowError( + "source changed since preparation; return reconciliation or a decision is required" + ) + git_root(runner, workspace) + gate_policy(workspace, existing=plan["mode"] == "existing") + baseline = load(task / "scaffold.json") + current = inventory(workspace) + protected = { + name + for name in baseline + if name in {"justfile", "pyproject.toml", ".copier-answers.yml"} + or name.startswith("tests/policy/") + } + if any(current.get(name) != baseline[name] for name in protected): + raise WorkflowError( + "gate configuration differs from the prepared reviewed policy; prepare a new plan" + ) + for name in [*names(plan, "source"), *names(plan, "test")]: + if not confined(workspace, name).is_file(): + raise WorkflowError("empty or missing product target") + if plan["mode"] == "new": + scaffold = load(task / "scaffold.json") + candidate = inventory(workspace) + if all(scaffold.get(name) == candidate.get(name) for name in names(plan, "test")): + raise WorkflowError("scaffold-only tests are not product evidence") + runner.env["PYTHONPATH"] = os_path(workspace, names(plan, "source")) + _ = runner.run(["uv", "sync", "--locked"], workspace) + stage_candidate(workspace, runner) + before = binding(state, runner) + _ = runner.run(["just", "ci"], workspace) + product = product_evidence(plan, workspace, workspace, runner) + repository_checks(plan, _repository(plan, task, workspace, runner), workspace, runner) + if binding(state, runner) != before: + raise WorkflowError("candidate changed while checks were running") + if plan["mode"] != "new" and inventory(Path(text(plan, "source"))) != load( + task / "source.json" + ): + raise WorkflowError("source changed while checks were running") + receipt: Record = {"binding": before, **product, "status": "passed"} + receipt["artifacts"] = { + path.relative_to(task).as_posix(): digest(path.read_bytes()) + for path in runner.evidence.iterdir() + if path.is_file() + } + token = digest(encoded(receipt)) + private_directory(task / "receipts") + save(task / "receipts" / (token + ".json"), receipt) + return {**state, **product, "status": "verified", "receipt": token} diff --git a/automation/src/kickstarter_workflow/workflow.py b/automation/src/kickstarter_workflow/workflow.py new file mode 100644 index 0000000..c9cafd9 --- /dev/null +++ b/automation/src/kickstarter_workflow/workflow.py @@ -0,0 +1,172 @@ +"""Task-owned Python development workflow.""" + +import fcntl +import os +import uuid +from pathlib import Path + +from kickstarter_workflow.files import confined +from kickstarter_workflow.integrity import validate_preparation +from kickstarter_workflow.process import Runner +from kickstarter_workflow.records import ( + Record, + WorkflowError, + identifier, + load, + object_value, + private_directory, + project_record, + save, + text, +) +from kickstarter_workflow.setup import prepare +from kickstarter_workflow.transfer import cleanup, effects, return_product +from kickstarter_workflow.verification import validate_receipt, verify + + +class Workflow: + """Prepare and validate isolated work without granting publication authority.""" + + def __init__(self, state_root: Path) -> None: + """Select an external durable state directory.""" + self.state_root: Path = state_root + + def _task(self, request: Record) -> Path: + if "source" in request: + source = Path(text(request, "source")).resolve() + if self.state_root.resolve().is_relative_to(source) or source.is_relative_to( + self.state_root.resolve() + ): + raise WorkflowError("source and external state must be separate") + private_directory(self.state_root) + project = self.state_root / identifier(request, "project") + private_directory(project) + task = project / identifier(request, "task") + private_directory(task) + return task + + def dispatch(self, operation: str, request: Record) -> Record: + """Execute one workflow operation and return a structured outcome.""" + try: + task = self._task(request) + descriptor = os.open(task / "lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, "w"): + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + return self._operate(operation, request, task) + except (WorkflowError, OSError, ValueError) as error: + return {"status": "blocked", "reason": str(error)} + + def _runner(self, plan: Record, task: Path) -> Runner: + return Runner( + object_value(plan, "tools"), + task / ("run-" + uuid.uuid4().hex), + self.state_root / "cache", + ) + + def _read_state(self, task: Path) -> Record: + path = task / "task.json" + metadata = path.stat() + if ( + metadata.st_uid != os.getuid() + or metadata.st_mode & 0o777 != 0o600 + or metadata.st_nlink != 1 + ): + raise WorkflowError("task record must be private and independently owned") + state = load(path) + plan = object_value(state, "plan") + if identifier(plan, "task") != task.name or identifier(plan, "project") != task.parent.name: + raise WorkflowError("task ownership mismatch") + status = text(state, "status") + if status in {"exempt", "preparing", "cleaned"}: + return state + workspace = Path(text(state, "workspace")) + expected = Path(text(plan, "source")) if plan.get("mode") == "new" else task / "workspace" + if workspace != expected or not all( + confined(workspace, name).is_file() + for name in (".copier-answers.yml", "pyproject.toml", "justfile") + ): + raise WorkflowError("workspace ownership or setup artifacts are missing") + validate_preparation(state, task, self._runner(plan, task)) + return state + + def _operate(self, operation: str, request: Record, task: Path) -> Record: + record = task / "task.json" + project_record(task.parent) + if any(task.glob("*.pending")): + raise WorkflowError("pending state write requires explicit recovery; work preserved") + if operation == "prepare": + return self._prepare(request, task) + state = self._read_state(task) + plan = object_value(state, "plan") + if "mode" in request and request != plan: + raise WorkflowError("task plan changed; do not share mutable task ownership") + if operation in {"resume", "status"}: + return self._status(state, task) + if operation == "cleanup": + result = cleanup(state, task) + save(record, result) + return result + if "workspace" not in state: + raise WorkflowError("operation requires prepared production workspace") + if operation == "verify": + save(record, {**state, "status": "verifying"}) + try: + result = verify(state, task, self._runner(plan, task)) + except (WorkflowError, OSError, ValueError) as error: + save(record, {**state, "status": "blocked", "reason": str(error)}) + raise + save(record, result) + return result + if operation == "return": + result = return_product(state, request, task, self._runner(plan, task)) + save(record, result) + return result + raise WorkflowError("unsupported operation") + + def _status(self, state: Record, task: Path) -> Record: + if state["status"] in {"verified", "review_required"}: + _ = validate_receipt(state, task, self._runner(object_value(state, "plan"), task)) + if state["status"] == "returning": + return { + **state, + "applied": list(effects(load(task / "return.json"))), + "reason": "interrupted return; retry validates actual effects", + } + return state + + def _prepare(self, request: Record, task: Path) -> Record: + record = task / "task.json" + if record.exists(): + previous = load(record) + if previous.get("plan") == request: + return self._operate("resume", request, task) + if previous.get("status") != "exempt" or request.get("mode") == "disposable": + raise WorkflowError("task plan changed; prepare a separate task") + save(task / ("exemption-" + uuid.uuid4().hex + ".json"), previous) + if text(request, "mode") == "disposable": + result: Record = { + "status": "exempt", + "reason": text(request, "reason"), + "plan": request, + } + else: + save( + record, + { + "status": "preparing", + "plan": request, + "reason": "setup incomplete; preserve partial work", + }, + ) + result = prepare(request, task, self._runner(request, task)) + save(record, result) + project_record( + task.parent, + { + "project": text(request, "project"), + "latest_task": text(request, "task"), + "mapping": request.get("mapping"), + "source": request.get("source"), + }, + ) + return result diff --git a/automation/tests/__init__.py b/automation/tests/__init__.py new file mode 100644 index 0000000..ac830eb --- /dev/null +++ b/automation/tests/__init__.py @@ -0,0 +1 @@ +"""Maintained product tests and real-process fixture support.""" diff --git a/automation/tests/conftest.py b/automation/tests/conftest.py new file mode 100644 index 0000000..25c638f --- /dev/null +++ b/automation/tests/conftest.py @@ -0,0 +1,94 @@ +"""Real-process, real-Git fixtures; no synthetic Copier output.""" + +import os +import shutil +from pathlib import Path + +import pytest + +from kickstarter_workflow.records import Record + +PIN = "37adbeeea53a242ed25db602a35b9d719d95c8ae" +CANONICAL = "https://github.com/maybebyte/python-kickstarter.git" + + +def command(*args: str, cwd: Path) -> None: + executable = shutil.which(args[0]) + assert executable is not None + old = Path.cwd() + os.chdir(cwd) + try: + pid = os.posix_spawn(executable, args, dict(os.environ)) + _, status = os.waitpid(pid, 0) + assert os.waitstatus_to_exitcode(status) == 0 + finally: + os.chdir(old) + + +@pytest.fixture(scope="session") +def template_cache(tmp_path_factory: pytest.TempPathFactory) -> Path: + root = tmp_path_factory.mktemp("canonical-template") + repository = Path(__file__).resolve().parents[2] + command( + "git", + "clone", + "--quiet", + "--no-hardlinks", + str(repository), + str(root / "template"), + cwd=root, + ) + cache = root / "template" + command("git", "checkout", "--quiet", "--detach", PIN, cwd=cache) + command("git", "remote", "set-url", "origin", CANONICAL, cwd=cache) + return cache + + +@pytest.fixture +def plan(tmp_path: Path, template_cache: Path) -> Record: + source = tmp_path / "source" + source.mkdir() + command("git", "init", "--quiet", str(source), cwd=tmp_path) + _ = (source / "app.py").write_text( + '"""A maintained calculation."""\n\n\ndef twice(value: int) -> int:\n """Double a value."""\n return value * 2\n' + ) + _ = (source / "test_app.py").write_text( + '"""Product regression."""\n\nfrom app import twice\n\n\ndef test_twice() -> None:\n assert twice(3) == 6\n' + ) + _ = (source / "README.md").write_text("Product contract: double the input.\n") + tools: Record = {name: str(shutil.which(name)) for name in ("git", "copier", "uv", "just")} + return { + "project": "sample", + "task": "one", + "mode": "workbench", + "source": str(source), + "template": {"source": CANONICAL, "revision": PIN, "cache": str(template_cache)}, + "tools": tools, + "answers": { + "project_name": "Sample", + "package_name": "sample", + "author_name": "Test Author", + "author_email": "test@example.invalid", + "license": "ISC", + "python_version": "3.11", + "project_type": "library", + "description": "Contract fixture.", + }, + "mapping": [ + {"source": "app.py", "target": "product/app.py", "role": "source"}, + {"source": "test_app.py", "target": "product/test_app.py", "role": "test"}, + {"source": "README.md", "target": "product/README.md", "role": "context"}, + ], + "repository_checks": [ + [ + "uv", + "run", + "--project", + "{workspace}", + "pytest", + "--override-ini=addopts=", + "test_app.py", + ] + ], + "scope_review": "Complete source, test and context slice; no other repository gates exist.", + } diff --git a/automation/tests/policy/test_gates.py b/automation/tests/policy/test_gates.py new file mode 100644 index 0000000..c856bcd --- /dev/null +++ b/automation/tests/policy/test_gates.py @@ -0,0 +1,43 @@ +"""Config-literal pins: gates cannot be silently weakened. Stdlib only.""" + +import re +import tomllib +from pathlib import Path +from typing import cast + +ROOT = Path(__file__).resolve().parent.parent.parent +PYPROJECT = tomllib.loads((ROOT / "pyproject.toml").read_text()) + + +def test_coverage_floor_not_lowered() -> None: + assert PYPROJECT["tool"]["coverage"]["report"]["fail_under"] >= 85 + + +def test_type_checking_is_recommended() -> None: + assert PYPROJECT["tool"]["basedpyright"]["typeCheckingMode"] == "recommended" + + +def test_ruff_select_present() -> None: + select = cast("list[str]", PYPROJECT["tool"]["ruff"]["lint"]["select"]) + assert "ALL" in select + + +def test_agents_md_recipes_exist_in_justfile() -> None: + """docs-can't-lie: every `just ` named in AGENTS.md is a real recipe.""" + justfile = (ROOT / "justfile").read_text() + recipes = set(re.findall(r"^([a-z][a-z-]*):", justfile, re.MULTILINE)) + referenced = set(re.findall(r"just ([a-z][a-z-]*)", (ROOT / "AGENTS.md").read_text())) + assert referenced <= recipes, f"AGENTS.md names missing recipes: {referenced - recipes}" + + +def test_actions_are_sha_pinned() -> None: + """Every third-party `uses:` is a 40-char SHA + a v-prefixed version comment.""" + # SHA + a version comment (v[.minor[.patch]]); our pins carry the action's + # exact tag. The maintainer re-verifies the comment matches the SHA at bump time + # via `gh api .../tags`. + pattern = re.compile(r"uses:\s*\S+@([0-9a-f]{40})\s+#\s*v\d+(\.\d+){0,2}\b") + for wf in (ROOT / ".github" / "workflows").glob("*.yml"): + for line in wf.read_text().splitlines(): + stripped = line.strip() + if stripped.startswith(("- uses:", "uses:")): + assert pattern.search(stripped), f"unpinned action in {wf.name}: {stripped}" diff --git a/automation/tests/property/conftest.py b/automation/tests/property/conftest.py new file mode 100644 index 0000000..6cc6325 --- /dev/null +++ b/automation/tests/property/conftest.py @@ -0,0 +1,48 @@ +"""Hypothesis profiles and property-marker enforcement for the property suite.""" + +import os +from collections.abc import Generator +from pathlib import Path + +import pytest +from hypothesis import HealthCheck, settings + +settings.register_profile("dev", max_examples=25, deadline=None) +settings.register_profile( + "ci", + max_examples=300, + deadline=None, + derandomize=True, + print_blob=True, + suppress_health_check=[HealthCheck.too_slow], +) +settings.load_profile(os.environ.get("HYPOTHESIS_PROFILE", "dev")) + +_HERE = Path(__file__).parent + + +def _require_property_marker(items: list[pytest.Item]) -> None: + # Test the real marker, not `item.keywords` — the parent dir is named + # "property", so "property" is a node keyword on every item here regardless. + unmarked = [ + item.nodeid + for item in items + if item.path.is_relative_to(_HERE) and item.get_closest_marker("property") is None + ] + if unmarked: + listed = ", ".join(unmarked) + msg = f"tests/property/ files must set the property marker (missing: {listed})" + raise pytest.UsageError(msg) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_collection_modifyitems(items: list[pytest.Item]) -> Generator[None, object]: + """Enforce the property marker before `-m property` deselects (and hides) violators. + + `just fuzz` runs `-m property`; an unmarked file here would otherwise be + silently deselected and run in no gate. Snapshot the fully-collected items + before the marker expression runs, then check after. + """ + snapshot = list(items) + yield + _require_property_marker(snapshot) diff --git a/automation/tests/property/test_review_properties.py b/automation/tests/property/test_review_properties.py new file mode 100644 index 0000000..190ac17 --- /dev/null +++ b/automation/tests/property/test_review_properties.py @@ -0,0 +1,84 @@ +"""Generated path transport through public preparation and the real generated gate.""" + +import uuid +from pathlib import Path + +import pytest +from hypothesis import event, example, given +from hypothesis import strategies as st + +from kickstarter_workflow.process import Runner +from kickstarter_workflow.records import Record, object_value, text +from kickstarter_workflow.workflow import Workflow + +pytestmark = pytest.mark.property + + +def test_generated_mapped_paths_arrive_as_one_literal_test_target( + tmp_path: Path, plan: Record +) -> None: + workflow = Workflow(tmp_path / "state") + + @given(st.text(alphabet="abc XYZ09é'\"$;&()", min_size=1, max_size=20)) + @example("ordinary tests") + @example("$(printf literal)") + @example("`printf literal`") + def check(suffix: str) -> None: + task = "case-" + uuid.uuid4().hex + target = "product/case-" + suffix + "/test_app.py" + request: Record = { + **plan, + "task": task, + "mapping": [ + {"source": "app.py", "target": "product/app.py", "role": "source"}, + {"source": "test_app.py", "target": target, "role": "test"}, + ], + } + prepared = workflow.dispatch("prepare", request) + assert prepared["status"] == "prepared", prepared + workspace = Path(text(prepared, "workspace")) + runner = Runner(object_value(plan, "tools"), tmp_path / task, tmp_path / "cache") + runner.env["PYTHONPATH"] = str(workspace / "product") + junit = tmp_path / (task + ".xml") + runner.env["PYTEST_ADDOPTS"] = "--junitxml=" + str(junit) + _ = runner.run(["just", "test"], workspace, allow_failure=True) + assert runner.last_exit == 0, runner.last_output.read_text() + assert 'name="test_twice"' in junit.read_text() + assert (workspace / target).read_bytes() == ( + Path(text(plan, "source")) / "test_app.py" + ).read_bytes() + for character in set(suffix): + event(f"observed character {character!r}") + + check() + + +def test_generated_selector_syntax_never_reaches_tools(tmp_path: Path, plan: Record) -> None: + @given( + body=st.text(alphabet="abcXYZ09 '$(){}", max_size=20), + selector=st.sampled_from(["{{ 'literal' }}", "[", "]", "*", "?", "{a,b}", "::"]), + ) + @example(body="", selector="[") + @example(body="", selector="{{ 'literal' }}") + def check(body: str, selector: str) -> None: + task = "case-" + uuid.uuid4().hex + request: Record = { + **plan, + "task": task, + "mapping": [ + {"source": "app.py", "target": "product/app.py", "role": "source"}, + { + "source": "test_app.py", + "target": "product/case-" + selector + body + "/test_app.py", + "role": "test", + }, + ], + } + result = Workflow(tmp_path / "state").dispatch("prepare", request) + assert result["status"] == "blocked", result + directory = tmp_path / "state/sample" / task + assert not (directory / "workspace").exists() + assert not list(directory.glob("run-*/*.log")) + event("refused selector " + selector) + + check() diff --git a/automation/tests/property/test_workflow_properties.py b/automation/tests/property/test_workflow_properties.py new file mode 100644 index 0000000..394a374 --- /dev/null +++ b/automation/tests/property/test_workflow_properties.py @@ -0,0 +1,176 @@ +"""Generated public-interface safety invariants, with isolated example state.""" + +import os +import tempfile +from pathlib import Path + +import pytest +from hypothesis import example, given +from hypothesis import strategies as st + +from kickstarter_workflow.records import Record, load, save, text +from kickstarter_workflow.workflow import Workflow + +pytestmark = pytest.mark.property + + +@given( + st.lists( + st.sampled_from(["prepare", "resume", "status", "verify", "return", "cleanup"]), + min_size=1, + max_size=15, + ) +) +def test_disposable_lifecycle_never_grants_production_evidence(operations: list[str]) -> None: + with tempfile.TemporaryDirectory(dir=os.environ.get("TMPDIR")) as directory: + workflow = Workflow(Path(directory) / "state") + request: Record = { + "project": "example", + "task": "one", + "mode": "disposable", + "reason": "temporary diagnostic", + } + status = "absent" + for operation in operations: + result = workflow.dispatch(operation, request) + if operation == "prepare" and status == "absent": + status = "exempt" + elif operation == "cleanup" and status != "absent": + status = "cleaned" + expected = ( + "blocked" if status == "absent" or operation in {"verify", "return"} else status + ) + assert result["status"] == expected, (operations, operation, result) + assert "receipt" not in result + assert "workspace" not in result + + +def test_generated_unsafe_paths_cannot_escape_the_task(plan: Record) -> None: + @given( + st.sampled_from(["../", "product/../../../", "product//", "product/./"]), + st.text(alphabet="abcdefghijklmnopqrstuvwxyz0123456789", min_size=1, max_size=12), + ) + @example(prefix="product//", name="app") + def check(prefix: str, name: str) -> None: + with tempfile.TemporaryDirectory(dir=os.environ.get("TMPDIR")) as directory: + root = Path(directory) + workflow = Workflow(root / "state") + target = prefix + name + ".py" + request: Record = { + **plan, + "mapping": [ + {"source": "app.py", "target": target, "role": "source"}, + {"source": "test_app.py", "target": "product/test_app.py", "role": "test"}, + ], + } + result = workflow.dispatch("prepare", request) + assert result["status"] == "blocked", result + assert "receipt" not in result + assert not (root / (name + ".py")).exists() + assert not (root / "state/sample/one/workspace").exists() + + check() + + +def test_arbitrary_candidate_config_test_and_mode_changes_invalidate_receipts( + tmp_path: Path, plan: Record +) -> None: + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + accepted = workflow.dispatch("verify", plan) + assert accepted["status"] == "verified", accepted + workspace = Path(text(prepared, "workspace")) + + @given( + name=st.sampled_from( + ["product/app.py", "product/test_app.py", "pyproject.toml", "uv.lock", "justfile"] + ), + suffix=st.binary(min_size=1, max_size=64), + mode_change=st.booleans(), + ) + def check(name: str, suffix: bytes, *, mode_change: bool) -> None: + path = workspace / name + original, mode = path.read_bytes(), path.stat().st_mode & 0o777 + try: + if mode_change: + path.chmod(mode ^ 0o100) + else: + _ = path.write_bytes(original + suffix) + rejected = workflow.dispatch("status", plan) + assert rejected["status"] == "blocked", rejected + assert "stale" in text(rejected, "reason") + finally: + _ = path.write_bytes(original) + path.chmod(mode) + restored = workflow.dispatch("resume", plan) + assert restored["status"] == "verified", restored + assert restored["receipt"] == accepted["receipt"] + + check() + + +def test_prepared_inventory_and_baseline_corruption_never_resumes( + tmp_path: Path, plan: Record +) -> None: + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + task = Path(text(prepared, "workspace")).parent + + @given( + name=st.sampled_from(["source.json", "scaffold.json", "baseline/app.py"]), + suffix=st.binary(min_size=1, max_size=64), + change_mode=st.booleans(), + ) + def check(name: str, suffix: bytes, *, change_mode: bool) -> None: + path = task / name + original, mode = path.read_bytes(), path.stat().st_mode & 0o777 + try: + if change_mode: + path.chmod(mode ^ 0o100) + else: + _ = path.write_bytes(original + suffix) + for operation in ("resume", "prepare"): + result = workflow.dispatch(operation, plan) + assert result["status"] == "blocked", (name, result) + finally: + _ = path.write_bytes(original) + path.chmod(mode) + assert workflow.dispatch("resume", plan)["status"] == "prepared" + + check() + + +def test_receipt_metadata_changes_invalidate_public_status(tmp_path: Path, plan: Record) -> None: + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + accepted = workflow.dispatch("verify", plan) + assert accepted["status"] == "verified", accepted + token = text(accepted, "receipt") + receipt = tmp_path / "state/sample/one/receipts" / (token + ".json") + original = receipt.read_bytes() + + @given( + field=st.sampled_from(["product_tests", "product_coverage", "annotation"]), + replacement=st.text(min_size=1, max_size=32), + ) + @example(field="product_tests", replacement="0") + def check(field: str, replacement: str) -> None: + changed = load(receipt) + assert changed.get(field) != replacement + changed[field] = replacement + try: + save(receipt, changed) + assert load(receipt)[field] == replacement + result = workflow.dispatch("status", plan) + assert result["status"] == "blocked", result + assert "receipt" in text(result, "reason") + finally: + _ = receipt.write_bytes(original) + restored = workflow.dispatch("resume", plan) + assert restored["status"] == "verified", restored + assert restored["receipt"] == token + + check() diff --git a/automation/tests/unit/test_acceptance.py b/automation/tests/unit/test_acceptance.py new file mode 100644 index 0000000..ded5c3d --- /dev/null +++ b/automation/tests/unit/test_acceptance.py @@ -0,0 +1,156 @@ +"""Acceptance refusals with real preparation, gates and measured product execution.""" + +import shutil +from pathlib import Path + +import pytest + +from kickstarter_workflow.files import string_list +from kickstarter_workflow.records import Record, load, object_value, text +from kickstarter_workflow.workflow import Workflow +from tests.conftest import command + + +def _files(root: Path) -> dict[str, tuple[bytes, int]]: + return { + path.relative_to(root).as_posix(): (path.read_bytes(), path.stat().st_mode & 0o777) + for path in root.rglob("*") + if path.is_file() + } + + +def _commands(task: Path) -> list[Record]: + return [load(path) for path in task.glob("run-*/**/*.json") if "argv" in load(path)] + + +def test_enclosing_home_is_refused_and_exact_root_preserves_external_state( + tmp_path: Path, plan: Record, monkeypatch: pytest.MonkeyPatch +) -> None: + home = tmp_path / "home" + home.mkdir() + command("git", "init", "--quiet", "--template=", str(home), cwd=tmp_path) + source = home / "project" + _ = shutil.copytree(Path(text(plan, "source")), source, ignore=shutil.ignore_patterns(".git")) + hooks = home / "global-hooks" + hooks.mkdir() + marker = home / "hook-executed" + hook = hooks / "post-checkout" + _ = hook.write_text(f"#!/bin/sh\n: > '{marker}'\n") + hook.chmod(0o700) + _ = (home / ".gitconfig").write_text(f"[core]\n\thooksPath = {hooks}\n") + installation = home / ".local/bin/installed-tool" + installation.parent.mkdir(parents=True) + _ = installation.write_text("Existing installation; not a task dependency.\n") + _ = (home / "unrelated.txt").write_text("Outside the declared source.\n") + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(home / ".gitconfig")) + request: Record = {**plan, "source": str(source)} + workflow = Workflow(tmp_path / "state") + before = _files(home) + rejected = workflow.dispatch("prepare", request) + assert rejected["status"] == "blocked", rejected + task = tmp_path / "state/sample/one" + assert not (task / "workspace").exists() + assert not (task / "source.json").exists() + assert any(item["cwd"] == str(source) and item["exit"] == 0 for item in _commands(task)) + assert _files(home) == before + + command("git", "init", "--quiet", "--template=", str(source), cwd=tmp_path) + local_hook = source / ".git/hooks/pre-commit" + local_hook.parent.mkdir() + _ = local_hook.write_text("#!/bin/sh\nexit 0\n") + local_hook.chmod(0o700) + before = _files(home) + accepted = workflow.dispatch("prepare", {**request, "task": "exact-root"}) + assert accepted["status"] == "prepared", accepted + workspace = Path(text(accepted, "workspace")) + assert workspace != source + assert set(load(workspace.parent / "source.json")) == {"app.py", "test_app.py", "README.md"} + assert not (workspace / ".git/hooks").exists() + assert not marker.exists() + assert _files(home) == before + + +def test_new_mode_refuses_unchanged_generated_example_tests(tmp_path: Path, plan: Record) -> None: + request: Record = { + **plan, + "mode": "new", + "source": str(tmp_path / "new-project"), + "isolate_git": True, + "mapping": [ + { + "source": "src/sample/__init__.py", + "target": "src/sample/__init__.py", + "role": "source", + }, + { + "source": "tests/unit/test_example.py", + "target": "tests/unit/test_example.py", + "role": "test", + }, + ], + "repository_checks": [["just", "ci"]], + } + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", request) + assert prepared["status"] == "prepared", prepared + workspace = Path(text(prepared, "workspace")) + example = (workspace / "tests/unit/test_example.py").read_bytes() + rejected = workflow.dispatch("verify", request) + assert rejected["status"] == "blocked", rejected + assert "receipt" not in rejected + assert (workspace / "tests/unit/test_example.py").read_bytes() == example + assert not (tmp_path / "state/sample/one/receipts").exists() + assert workflow.dispatch("status", request)["status"] == "blocked" + + +@pytest.mark.parametrize("failure", ["zero", "skipped", "inadequate"]) +def test_measured_product_run_refuses_inadequate_evidence_after_real_ci_passes( + tmp_path: Path, plan: Record, failure: str +) -> None: + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + workspace = Path(text(prepared, "workspace")) + tests = workspace / "product/test_app.py" + if failure == "inadequate": + _ = (workspace / "tests/unit/test_control.py").write_bytes(tests.read_bytes()) + _ = tests.write_text( + '"""A test that imports but does not exercise the product."""\n\nfrom app import twice\n\n\ndef test_import_only() -> None:\n assert callable(twice)\n assert twice.__name__ == "twice"\n assert twice.__module__ == "app"\n' + ) + else: + _ = tests.write_text( + '"""Module collection exercises the product, but no successful test does."""\n\n' + + ("import pytest\n" if failure == "skipped" else "") + + "from app import twice\n\nassert twice(3) == 6\n" + + ( + '\n\n@pytest.mark.skip(reason="Explicit nonexecution fixture.")\ndef test_not_executed() -> None:\n assert twice(3) == 6\n' + if failure == "skipped" + else "" + ) + ) + result = workflow.dispatch("verify", plan) + assert result["status"] == "blocked", result + task = workspace.parent + commands = _commands(task) + assert any(string_list(item, "argv")[1:] == ["ci"] and item["exit"] == 0 for item in commands) + product_runs = [ + item for item in commands if "--override-ini=addopts=" in string_list(item, "argv") + ] + assert len(product_runs) == 1 + assert product_runs[0]["exit"] == (5 if failure == "zero" else 0) + evidence = Path(text(product_runs[0], "output")).parent + junit = (evidence / "product-tests.xml").read_text() + coverage = object_value(load(evidence / "product-coverage.json"), "files") + summary = object_value(object_value(coverage, "product/app.py"), "summary") + if failure == "zero": + assert " None: + request = tmp_path / "request.json" + _ = request.write_text( + json.dumps( + {"project": "demo", "task": "one", "mode": "disposable", "reason": "one-off probe"} + ) + ) + args = ["--state", str(tmp_path / "state"), "prepare", str(request)] + assert main(args) == 0 + assert '"status": "exempt"' in capsys.readouterr().out + _ = request.write_text("not json") + assert main(args) == 2 + assert '"status": "blocked"' in capsys.readouterr().out diff --git a/automation/tests/unit/test_configuration.py b/automation/tests/unit/test_configuration.py new file mode 100644 index 0000000..d7cd3a6 --- /dev/null +++ b/automation/tests/unit/test_configuration.py @@ -0,0 +1,82 @@ +"""Reuse the actual generated gates and threshold, without weakening them.""" + +from pathlib import Path + +from kickstarter_workflow.records import Record, object_value, text +from kickstarter_workflow.workflow import Workflow +from tests.conftest import PIN, command + + +def test_existing_optional_gates_and_approved_coverage_are_preserved( + tmp_path: Path, plan: Record, template_cache: Path +) -> None: + source = tmp_path / "existing" + answers = [ + arg + for key, value in object_value(plan, "answers").items() + for arg in ("--data", f"{key}={value}") + ] + command( + "copier", + "copy", + "--defaults", + "--skip-tasks", + "--vcs-ref", + PIN, + *answers, + "--data", + "enable_precommit_install=false", + "--data", + "enable_property_tests=false", + "--data", + "enable_dependency_audit=false", + "--data", + "coverage_floor=60", + "--data", + "package_name=sample", + "--data", + "ruff_ruleset=curated", + str(template_cache), + str(source), + cwd=tmp_path, + ) + command("git", "-c", "init.templateDir=", "init", "--quiet", str(source), cwd=tmp_path) + (source / "src/sample/core.py").unlink() + _ = (source / "src/sample/__init__.py").write_text( + '"""Existing product."""\n\n\ndef twice(value: int) -> int:\n """Double the magnitude."""\n if value < 0:\n return -value * 2\n return value * 2\n' + ) + _ = (source / "tests/unit/test_example.py").write_text( + '"""Established partial coverage."""\n\nfrom sample import twice\n\n\ndef test_twice() -> None:\n assert twice(3) == 6\n' + ) + request: Record = { + **plan, + "mode": "existing", + "source": str(source), + "mapping": [ + { + "source": "src/sample/__init__.py", + "target": "src/sample/__init__.py", + "role": "source", + }, + { + "source": "tests/unit/test_example.py", + "target": "tests/unit/test_example.py", + "role": "test", + }, + ], + "repository_checks": [["just", "ci"]], + } + original = (source / "pyproject.toml").read_bytes() + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", request) + assert prepared["status"] == "prepared", prepared + workspace = Path(text(prepared, "workspace")) + assert (workspace / "pyproject.toml").read_bytes() == original + verified = workflow.dispatch("verify", request) + assert verified["status"] == "verified", verified + assert 60 <= int(str(verified["product_coverage"])) < 85 + config = workspace / "pyproject.toml" + _ = config.write_text(config.read_text().replace("fail_under = 60", "fail_under = 1")) + assert workflow.dispatch("verify", request)["status"] == "blocked" + assert object_value(request, "template")["revision"] == PIN + assert (source / "pyproject.toml").read_bytes() == original diff --git a/automation/tests/unit/test_destination.py b/automation/tests/unit/test_destination.py new file mode 100644 index 0000000..c1ed43a --- /dev/null +++ b/automation/tests/unit/test_destination.py @@ -0,0 +1,94 @@ +"""Destination checks cannot certify a different payload or stale evidence.""" + +from pathlib import Path + +import pytest + +from kickstarter_workflow.records import Record, load, text +from kickstarter_workflow.workflow import Workflow +from tests.unit.test_transfer import review + + +@pytest.mark.parametrize("changed", ["app.py", "README.md", "workspace", "evidence"]) +def test_destination_checks_must_preserve_reviewed_inputs( + tmp_path: Path, plan: Record, changed: str +) -> None: + source = Path(text(plan, "source")) + task = tmp_path / "state/sample/one" + workspace = task / "workspace" + if changed == "workspace": + target = repr(str(workspace / "product/app.py")) + elif changed == "evidence": + target = ( + f"Path({str(task)!r}) / next(name for name in " + f"json.loads((Path({str(task)!r}) / 'receipts' / " + f"(json.loads((Path({str(task)!r}) / 'task.json').read_text())['receipt'] + '.json'))" + ".read_text())['artifacts'] if name.endswith('.log'))" + ) + else: + target = repr(str(source / changed)) + mutation = ( + "from pathlib import Path\nimport json\n" + f"if Path.cwd() == Path({str(source)!r}):\n" + f" p = Path({target})\n" + " p.write_bytes(p.read_bytes() + b'\\n# normalized\\n')\n" + ) + checks = plan["repository_checks"] + assert isinstance(checks, list) + checks.append(["uv", "run", "--project", "{workspace}", "python", "-c", mutation]) + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + app = workspace / "product/app.py" + _ = app.write_text(app.read_text().replace("value * 2", "value + value")) + verified = workflow.dispatch("verify", plan) + assert verified["status"] == "verified", verified + result = workflow.dispatch("return", review(text(verified, "receipt"), "app.py")) + assert result["status"] == "blocked", result + assert result["destination_validated"] is False + assert result["transferred"] == ["app.py"] + assert result["applied"] == ([] if changed == "app.py" else ["app.py"]) + journal = load(task / "return.json") + assert journal["status"] == "blocked" + assert "value + value" in (source / "app.py").read_text() + if changed in {"app.py", "README.md"}: + assert result["destination_changes"] == [changed] + assert "normalized" in (source / changed).read_text() + + +@pytest.mark.parametrize(("field", "replacement"), [("status", "failed"), ("product_tests", 0)]) +def test_destination_checks_cannot_modify_the_verification_receipt( + tmp_path: Path, plan: Record, field: str, replacement: str | int +) -> None: + source = Path(text(plan, "source")) + task = tmp_path / "state/sample/one" + mutation = ( + "from pathlib import Path\nimport json\n" + f"if Path.cwd() == Path({str(source)!r}):\n" + f" task = Path({str(task)!r})\n" + " token = json.loads((task / 'task.json').read_text())['receipt']\n" + " path = task / 'receipts' / (token + '.json')\n" + " receipt = json.loads(path.read_text())\n" + f" receipt[{field!r}] = {replacement!r}\n" + " path.write_text(json.dumps(receipt))\n" + ) + checks = plan["repository_checks"] + assert isinstance(checks, list) + checks.append(["uv", "run", "--project", "{workspace}", "python", "-c", mutation]) + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + app = Path(text(prepared, "workspace")) / "product/app.py" + _ = app.write_text(app.read_text().replace("value * 2", "value + value")) + verified = workflow.dispatch("verify", plan) + assert verified["status"] == "verified", verified + token = text(verified, "receipt") + assert load(task / "receipts" / (token + ".json"))["status"] == "passed" + result = workflow.dispatch("return", review(token, "app.py")) + assert load(task / "receipts" / (token + ".json"))[field] == replacement + assert result["status"] == "blocked", result + assert result["destination_validated"] is False + assert "receipt" in text(result, "reason") + assert result["transferred"] == ["app.py"] + assert result["applied"] == ["app.py"] + assert load(task / "return.json")["status"] == "blocked" diff --git a/automation/tests/unit/test_environment.py b/automation/tests/unit/test_environment.py new file mode 100644 index 0000000..419b5bb --- /dev/null +++ b/automation/tests/unit/test_environment.py @@ -0,0 +1,131 @@ +"""Declared product dependencies and package data survive workbench preparation.""" + +from pathlib import Path + +import pytest + +from kickstarter_workflow.records import Record, text +from kickstarter_workflow.workflow import Workflow +from tests.unit.test_transfer import review + + +def test_declared_product_dependency_is_available_before_policy_freeze( + tmp_path: Path, plan: Record +) -> None: + source = Path(text(plan, "source")) + metadata = ( + '[project]\nname = "original-product"\nversion = "1.0.0"\n' + 'requires-python = ">=3.11"\ndependencies = ["humanize==4.13.0"]\n' + ) + _ = (source / "pyproject.toml").write_text(metadata) + _ = (source / "app.py").write_text( + '"""Product dependency fixture."""\n\nimport humanize\n\n\ndef twice(value: int) -> str:\n """Format a doubled value."""\n return humanize.intcomma(value * 2)\n' + ) + test = source / "test_app.py" + _ = test.write_text(test.read_text().replace("== 6", '== "6"')) + mapping = plan["mapping"] + assert isinstance(mapping, list) + mapping.append( + {"source": "pyproject.toml", "target": "product/pyproject.toml", "role": "context"} + ) + plan["environment"] = {"project": "pyproject.toml", "groups": [], "extras": []} + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + workspace = Path(text(prepared, "workspace")) + frozen = (workspace / "pyproject.toml").read_bytes() + verified = workflow.dispatch("verify", plan) + assert verified["status"] == "verified", verified + assert verified["product_coverage"] == 100 + assert (workspace / "pyproject.toml").read_bytes() == frozen + assert (source / "pyproject.toml").read_text() == metadata + assert 'name = "humanize"' in (workspace / "uv.lock").read_text() + + +def test_local_package_metadata_assets_and_destination_imports_are_preserved( + tmp_path: Path, plan: Record +) -> None: + source = Path(text(plan, "source")) + (source / "app.py").unlink() + package = source / "src/original_product" + package.mkdir(parents=True) + _ = (package / "label.txt").write_text("Double") + _ = (package / "__init__.py").write_text( + '"""Installed product."""\n\nfrom importlib.resources import files\n\nimport humanize\n\n\ndef twice(value: int) -> str:\n """Use packaged data and a declared dependency."""\n label = files("original_product").joinpath("label.txt").read_text()\n return label + " " + humanize.intcomma(value * 2)\n' + ) + metadata = '[project]\nname = "original-product"\nversion = "1.0.0"\nrequires-python = ">=3.11"\ndependencies = ["humanize==4.13.0"]\nreadme = "README.md"\n[build-system]\nrequires = ["uv_build==0.11.23"]\nbuild-backend = "uv_build"\n' + _ = (source / "pyproject.toml").write_text(metadata) + _ = (source / "test_app.py").write_text( + '"""Real distribution and execution layout."""\n\nfrom importlib.metadata import version\nfrom pathlib import Path\n\nimport original_product\n\n\ndef test_package() -> None:\n assert original_product.twice(3) == "Double 6"\n assert version("original-product") == "1.0.0"\n assert original_product.__file__ is not None\n assert Path(original_product.__file__).is_relative_to(Path(__file__).parent / "src")\n' + ) + plan["mapping"] = [ + {"source": name, "target": "product/" + name, "role": role} + for name, role in [ + ("src/original_product/__init__.py", "source"), + ("src/original_product/label.txt", "context"), + ("test_app.py", "test"), + ("pyproject.toml", "context"), + ("README.md", "context"), + ] + ] + plan["environment"] = {"project": "pyproject.toml", "groups": [], "extras": []} + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + workspace = Path(text(prepared, "workspace")) + module = workspace / "product/src/original_product/__init__.py" + _ = module.write_text(module.read_text().replace("value * 2", "value + value")) + verified = workflow.dispatch("verify", plan) + assert verified["status"] == "verified", verified + result = workflow.dispatch( + "return", review(text(verified, "receipt"), "src/original_product/__init__.py") + ) + assert result["status"] == "returned", result + assert result["destination_validated"] is True + assert (source / "pyproject.toml").read_text() == metadata + + +@pytest.mark.parametrize( + "damage", + [ + "mapping", + "missing-selection", + "dynamic", + "uv-sources", + "backend", + "groups", + "extras", + "compatibility", + ], +) +def test_unsupported_product_environments_block_before_readiness( + tmp_path: Path, plan: Record, damage: str +) -> None: + source = Path(text(plan, "source")) + metadata = '[project]\nname = "original-product"\nversion = "1.0.0"\nrequires-python = ">=3.11"\ndependencies = []\n' + if damage == "dynamic": + metadata += 'dynamic = ["version"]\n' + elif damage == "uv-sources": + metadata += '[tool.uv.sources]\nexternal = { path = "../other" }\n' + elif damage == "backend": + metadata += ( + '[build-system]\nrequires = ["setuptools"]\nbuild-backend = "setuptools.build_meta"\n' + ) + elif damage == "compatibility": + metadata = metadata.replace(">=3.11", "<3.10") + _ = (source / "pyproject.toml").write_text(metadata) + mapping = plan["mapping"] + assert isinstance(mapping, list) + if damage != "mapping": + mapping.append( + {"source": "pyproject.toml", "target": "product/pyproject.toml", "role": "context"} + ) + if damage != "missing-selection": + plan["environment"] = { + "project": "pyproject.toml", + "groups": ["undeclared"] if damage == "groups" else [], + "extras": ["undeclared"] if damage == "extras" else [], + } + result = Workflow(tmp_path / "state").dispatch("prepare", plan) + assert result["status"] == "blocked", result + assert (source / "pyproject.toml").read_text() == metadata diff --git a/automation/tests/unit/test_git_worktrees.py b/automation/tests/unit/test_git_worktrees.py new file mode 100644 index 0000000..f5bc623 --- /dev/null +++ b/automation/tests/unit/test_git_worktrees.py @@ -0,0 +1,80 @@ +"""Git administrative files are context, never product payload.""" + +import shutil +from pathlib import Path + +import pytest + +from kickstarter_workflow.records import Record, load, text +from kickstarter_workflow.workflow import Workflow +from tests.conftest import command + + +def _contents(*roots: Path) -> dict[Path, tuple[bytes, int]]: + return { + path: (path.read_bytes(), path.stat().st_mode & 0o777) + for root in roots + for path in root.rglob("*") + if path.is_file() + } + + +@pytest.mark.parametrize("layout", ["linked-worktree", "separate-git-dir"]) +def test_gitfile_source_prepares_and_resumes_without_copying_administration( + tmp_path: Path, plan: Record, template_cache: Path, layout: str +) -> None: + source = tmp_path / "gitfile-source" + administration = tmp_path / "administration" + if layout == "linked-worktree": + command( + "git", + "clone", + "--quiet", + "--no-hardlinks", + "--no-checkout", + "--template=", + str(template_cache), + str(administration), + cwd=tmp_path, + ) + command( + "git", + "-c", + "core.hooksPath=/dev/null", + "worktree", + "add", + "--quiet", + "--detach", + "--no-checkout", + str(source), + "HEAD", + cwd=administration, + ) + else: + command( + "git", + "init", + "--quiet", + "--template=", + "--separate-git-dir", + str(administration), + str(source), + cwd=tmp_path, + ) + assert (source / ".git").is_file() + original = Path(text(plan, "source")) + for name in ("app.py", "test_app.py", "README.md"): + _ = shutil.copy2(original / name, source / name) + before = _contents(source, administration) + request: Record = {**plan, "source": str(source)} + workflow = Workflow(tmp_path / "gitfile-state") + prepared = workflow.dispatch("prepare", request) + assert prepared["status"] == "prepared", prepared + workspace = Path(text(prepared, "workspace")) + assert (workspace / ".git").is_dir() + assert set(load(workspace.parent / "source.json")) == {"app.py", "test_app.py", "README.md"} + for name in ("app.py", "test_app.py", "README.md"): + assert (workspace / "product" / name).read_bytes() == (source / name).read_bytes() + assert workflow.dispatch("resume", request)["status"] == "prepared" + assert workflow.dispatch("prepare", request)["workspace"] == str(workspace) + assert _contents(source, administration) == before diff --git a/automation/tests/unit/test_intent.py b/automation/tests/unit/test_intent.py new file mode 100644 index 0000000..b1f5abd --- /dev/null +++ b/automation/tests/unit/test_intent.py @@ -0,0 +1,31 @@ +"""Textual merge success does not establish compatibility of intent.""" + +from pathlib import Path + +from kickstarter_workflow.records import Record, text +from kickstarter_workflow.workflow import Workflow +from tests.unit.test_transfer import review + + +def test_clean_text_merge_cannot_approve_discarded_behavior(tmp_path: Path, plan: Record) -> None: + source = Path(text(plan, "source")) + original = '"""Product with a policy multiplier."""\n\nFACTOR = 2\nLABEL = "calculation"\nUNIT = "items"\nVERSION = "1"\n\n\ndef twice(value: int) -> int:\n """Apply the multiplier."""\n return value * FACTOR\n' + _ = (source / "app.py").write_text(original) + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + workspace = Path(text(prepared, "workspace")) + _ = (workspace / "product/app.py").write_text( + original.replace("value * FACTOR", "value + value") + ) + verified = workflow.dispatch("verify", plan) + assert verified["status"] == "verified", verified + newer = original.replace("FACTOR = 2", "FACTOR = 3") + _ = (source / "app.py").write_text(newer) + result = workflow.dispatch("return", review(text(verified, "receipt"), "app.py")) + assert result["status"] == "review_required", result + assert result["receipt"] != verified["receipt"] + assert (source / "app.py").read_text() == newer + assert "FACTOR = 3" in (workspace / "product/app.py").read_text() + assert "return value + value" in (workspace / "product/app.py").read_text() + assert not (workspace.parent / "return.json").exists() diff --git a/automation/tests/unit/test_provenance.py b/automation/tests/unit/test_provenance.py new file mode 100644 index 0000000..b7d3737 --- /dev/null +++ b/automation/tests/unit/test_provenance.py @@ -0,0 +1,34 @@ +"""Literal regressions for cache trust and repository confinement.""" + +from pathlib import Path + +from kickstarter_workflow.records import Record, object_value, text +from kickstarter_workflow.workflow import Workflow +from tests.conftest import command + + +def test_moving_or_dirty_template_cache_never_provides_readiness( + tmp_path: Path, plan: Record, template_cache: Path +) -> None: + cache = tmp_path / "cache" + command( + "git", "clone", "--quiet", "--no-hardlinks", str(template_cache), str(cache), cwd=tmp_path + ) + command( + "git", + "remote", + "set-url", + "origin", + text(object_value(plan, "template"), "source"), + cwd=cache, + ) + command("git", "checkout", "--quiet", "-b", "moving", cwd=cache) + changed: Record = {**plan, "template": {**object_value(plan, "template"), "cache": str(cache)}} + result = Workflow(tmp_path / "state").dispatch("prepare", changed) + assert result["status"] == "blocked", result + assert "moving" in text(result, "reason") + command("git", "checkout", "--quiet", "--detach", cwd=cache) + _ = (cache / "unreviewed.txt").write_text("Unreviewed template content.\n") + result = Workflow(tmp_path / "other-state").dispatch("prepare", changed) + assert result["status"] == "blocked", result + assert "dirty" in text(result, "reason") diff --git a/automation/tests/unit/test_resume.py b/automation/tests/unit/test_resume.py new file mode 100644 index 0000000..56cf0e9 --- /dev/null +++ b/automation/tests/unit/test_resume.py @@ -0,0 +1,64 @@ +"""Prepared readiness requires intact retained state, not just three files.""" + +import shutil +from pathlib import Path + +import pytest + +from kickstarter_workflow.records import Record, text +from kickstarter_workflow.workflow import Workflow + + +@pytest.mark.parametrize( + "damage", + [ + "source-missing", + "source-corrupt", + "baseline-missing", + "baseline-tampered", + "git", + "policy", + "scaffold", + ], +) +def test_resume_revalidates_complete_preparation(tmp_path: Path, plan: Record, damage: str) -> None: + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + workspace = Path(text(prepared, "workspace")) + task = workspace.parent + product = workspace / "product/app.py" + _ = product.write_text(product.read_text().replace("value * 2", "value + value")) + assert workflow.dispatch("resume", plan)["status"] == "prepared" + assert workflow.dispatch("prepare", plan)["status"] == "prepared" + if damage == "source-missing": + (task / "source.json").unlink() + elif damage == "source-corrupt": + _ = (task / "source.json").write_text("{}\n") + elif damage == "baseline-missing": + shutil.rmtree(task / "baseline") + elif damage == "baseline-tampered": + _ = (task / "baseline/app.py").write_text("unreviewed baseline\n") + elif damage == "git": + shutil.rmtree(workspace / ".git") + elif damage == "scaffold": + _ = (task / "scaffold.json").write_text("{}\n") + else: + _ = (workspace / "justfile").write_text("ci:\n true\n") + for operation in ("resume", "prepare"): + rejected = workflow.dispatch(operation, plan) + assert rejected["status"] == "blocked", rejected + assert "value + value" in product.read_text() + assert (task / "task.json").is_file() + + +def test_resume_refuses_lost_prepared_environment(tmp_path: Path, plan: Record) -> None: + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + workspace = Path(text(prepared, "workspace")) + shutil.rmtree(workspace / ".venv") + for operation in ("resume", "prepare"): + result = workflow.dispatch(operation, plan) + assert result["status"] == "blocked", result + assert (workspace / "product/app.py").is_file() diff --git a/automation/tests/unit/test_review_concurrency.py b/automation/tests/unit/test_review_concurrency.py new file mode 100644 index 0000000..c0b7264 --- /dev/null +++ b/automation/tests/unit/test_review_concurrency.py @@ -0,0 +1,94 @@ +"""Force shared-record publication overlap in independent public workflow processes.""" + +import multiprocessing +import os +from multiprocessing.synchronize import Event +from pathlib import Path + +import pytest + +from kickstarter_workflow.records import Record, encoded, load +from kickstarter_workflow.workflow import Workflow + + +def _request(task: str) -> Record: + return { + "project": "sample", + "task": task, + "mode": "disposable", + "reason": "bounded concurrency regression", + } + + +def _prepare_child(root: Path, task: str, reached: Event, release: Event, done: Event) -> None: + original = os.open + pending = root / "sample/project.pending" + lock = root / "sample/project.lock" + + def controlled_open( + path: str | os.PathLike[str], flags: int, mode: int = 0o777, *, dir_fd: int | None = None + ) -> int: + if task == "two" and Path(path) in {pending, lock}: + reached.set() + descriptor = original(path, flags, mode, dir_fd=dir_fd) + if task == "one" and Path(path) == pending: + reached.set() + assert release.wait(10), "publication release timed out" + return descriptor + + with pytest.MonkeyPatch.context() as patch: + patch.setattr(os, "open", controlled_open) + result = Workflow(root).dispatch("prepare", _request(task)) + _ = (root.parent / (task + ".json")).write_bytes(encoded(result)) + done.set() + + +@pytest.mark.parametrize("attempt", range(3)) +def test_independent_tasks_wait_for_shared_publication(tmp_path: Path, attempt: int) -> None: + root = tmp_path / f"state-{attempt}" + context = multiprocessing.get_context("fork") + first, second, release, done_first, done_second = (context.Event() for _ in range(5)) + workers = [ + context.Process(target=_prepare_child, args=(root, "one", first, release, done_first)), + context.Process(target=_prepare_child, args=(root, "two", second, release, done_second)), + ] + workers[0].start() + try: + assert first.wait(10), "first process did not reach its real pending write" + workers[1].start() + assert second.wait(10), "second process did not reach the shared record boundary" + assert not done_second.wait(0.2), "second task falsely finished while publication was held" + finally: + release.set() + for worker in workers: + if worker.pid is not None: + worker.join(10) + if worker.is_alive(): + worker.kill() + worker.join(10) + assert [worker.exitcode for worker in workers] == [0, 0] + for task in ("one", "two"): + assert load(tmp_path / (task + ".json"))["status"] == "exempt" + result = Workflow(root).dispatch("resume", _request(task)) + assert result["status"] == "exempt", result + assert result["plan"] == _request(task) + assert load(root / "sample/project.json")["latest_task"] == "two" + assert not list(root.rglob("*.pending")) + + +@pytest.mark.parametrize("operation", ["status", "resume", "prepare", "cleanup"]) +def test_abandoned_project_write_blocks_readiness_and_preserves_state( + tmp_path: Path, operation: str +) -> None: + root = tmp_path / "state" + workflow = Workflow(root) + request = _request("one") + assert workflow.dispatch("prepare", request)["status"] == "exempt" + project = root / "sample" + before = (project / "one/task.json").read_bytes() + pending = project / "project.pending" + _ = pending.write_bytes(b"interrupted publication") + result = workflow.dispatch(operation, request) + assert result["status"] == "blocked", result + assert pending.read_bytes() == b"interrupted publication" + assert (project / "one/task.json").read_bytes() == before diff --git a/automation/tests/unit/test_review_git.py b/automation/tests/unit/test_review_git.py new file mode 100644 index 0000000..b00bf45 --- /dev/null +++ b/automation/tests/unit/test_review_git.py @@ -0,0 +1,187 @@ +"""Repository checks must measure the candidate, not an empty Git index.""" + +from pathlib import Path + +import pytest + +from kickstarter_workflow.process import Runner +from kickstarter_workflow.records import Record, load, object_value, text +from kickstarter_workflow.workflow import Workflow + + +@pytest.mark.parametrize("document", ["README.md", " leading.md"]) +def test_repository_owned_git_gate_rejects_the_actual_candidate( + tmp_path: Path, plan: Record, document: str +) -> None: + source = Path(text(plan, "source")) + if document != "README.md": + _ = (source / "README.md").rename(source / document) + mapping = plan["mapping"] + assert isinstance(mapping, list) + assert isinstance(mapping[2], dict) + mapping[2]["source"] = document + _ = (source / "justfile").write_text( + "ci:\n ! git grep -n FORBIDDEN_REVIEW_MARKER -- '*.md'\n" + ) + runner = Runner(object_value(plan, "tools"), tmp_path / "setup", tmp_path / "cache") + _ = runner.run(["git", "add", "app.py", "test_app.py", "justfile"], source) + index = (source / ".git/index").read_bytes() + config = (source / ".git/config").read_bytes() + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + workspace = Path(text(prepared, "workspace")) + _ = (workspace / "product/README.md").write_text("FORBIDDEN_REVIEW_MARKER\n") + result = workflow.dispatch("verify", plan) + assert result["status"] == "blocked", result + records = [load(p) for p in (tmp_path / "state/sample/one").glob("run-*/*.json")] + failed = [ + r + for r in records + if r.get("exit") == 1 + and "/repository-" in str(r.get("cwd")) + and r.get("argv") == [text(object_value(plan, "tools"), "just"), "ci"] + ] + assert len(failed) == 1, records + assert f"{document}:1:FORBIDDEN_REVIEW_MARKER" in Path(text(failed[0], "output")).read_text() + assert (source / ".git/index").read_bytes() == index + assert (source / ".git/config").read_bytes() == config + assert (source / document).read_text() == "Product contract: double the input.\n" + + +def test_repository_history_and_refs_survive_without_source_git_changes( + tmp_path: Path, plan: Record, template_cache: Path +) -> None: + source = Path(text(plan, "source")) + runner = Runner(object_value(plan, "tools"), tmp_path / "setup", tmp_path / "cache") + pin = text(object_value(plan, "template"), "revision") + _ = runner.run(["git", "fetch", "--no-tags", str(template_cache), pin], source) + _ = runner.run(["git", "update-ref", "refs/heads/review-base", pin], source) + _ = runner.run(["git", "symbolic-ref", "HEAD", "refs/heads/review-base"], source) + _ = (source / "justfile").write_text( + "\n".join( + [ + "ci:", + " git cat-file -e HEAD:copier.yml", + f' test "$(git rev-parse refs/heads/review-base)" = {pin}', + " git ls-files --error-unmatch README.md", + "", + ] + ) + ) + _ = runner.run(["git", "add", "app.py"], source) + before = { + p.relative_to(source).as_posix(): p.read_bytes() + for p in (source / ".git").rglob("*") + if p.is_file() + } + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + verified = workflow.dispatch("verify", plan) + assert verified["status"] == "verified", verified + after = { + p.relative_to(source).as_posix(): p.read_bytes() + for p in (source / ".git").rglob("*") + if p.is_file() + } + assert after == before + assert workflow.dispatch("status", plan)["status"] == "verified" + _ = runner.run(["git", "update-ref", "refs/heads/changed", pin], source) + assert workflow.dispatch("status", plan)["status"] == "blocked" + _ = runner.run(["git", "update-ref", "-d", "refs/heads/changed"], source) + assert workflow.dispatch("status", plan)["status"] == "verified" + workspace = Path(text(prepared, "workspace")) + _ = runner.run(["git", "update-index", "--force-remove", "product/app.py"], workspace) + assert workflow.dispatch("status", plan)["status"] == "blocked" + + +def test_unsupported_git_requirements_are_blocked_before_generation( + tmp_path: Path, plan: Record +) -> None: + plan["git_requirements"] = ["reflogs"] + result = Workflow(tmp_path / "state").dispatch("prepare", plan) + assert result["status"] == "blocked", result + assert not (tmp_path / "state/sample/one/workspace").exists() + + +def test_repository_check_cannot_change_the_measured_git_index( + tmp_path: Path, plan: Record +) -> None: + source = Path(text(plan, "source")) + _ = (source / "justfile").write_text("ci:\n git update-index --force-remove README.md\n") + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + result = workflow.dispatch("verify", plan) + assert result["status"] == "blocked", result + records = [load(p) for p in (tmp_path / "state/sample/one").glob("run-*/*.json")] + assert any( + r.get("exit") == 0 + and "/repository-" in str(r.get("cwd")) + and r.get("argv") == [text(object_value(plan, "tools"), "just"), "ci"] + for r in records + ) + + +@pytest.mark.parametrize("context", ["shallow", "grafts", "replacement"]) +def test_incomplete_or_rewritten_history_cannot_provide_readiness( + tmp_path: Path, plan: Record, template_cache: Path, context: str +) -> None: + source = Path(text(plan, "source")) + runner = Runner(object_value(plan, "tools"), tmp_path / "setup", tmp_path / "cache") + pin = text(object_value(plan, "template"), "revision") + depth = ["--depth=1"] if context == "shallow" else [] + _ = runner.run(["git", "fetch", "--no-tags", *depth, str(template_cache), pin], source) + _ = runner.run(["git", "update-ref", "HEAD", pin], source) + if context == "grafts": + (source / ".git/info").mkdir(exist_ok=True) + _ = (source / ".git/info/grafts").write_text(pin + "\n") + elif context == "replacement": + _ = runner.run(["git", "update-ref", "refs/replace/" + pin, pin], source) + result = Workflow(tmp_path / "state").dispatch("prepare", plan) + assert result["status"] == "blocked", result + + +def test_reused_project_gate_sees_untracked_candidate_files(tmp_path: Path, plan: Record) -> None: + workflow = Workflow(tmp_path / "state") + new: Record = { + **plan, + "mode": "new", + "source": str(tmp_path / "new-project"), + "isolate_git": True, + } + new["mapping"] = [ + {"source": "src/sample/__init__.py", "target": "src/sample/__init__.py", "role": "source"}, + { + "source": "tests/unit/test_example.py", + "target": "tests/unit/test_example.py", + "role": "test", + }, + ] + new["repository_checks"] = [["just", "ci"]] + generated = workflow.dispatch("prepare", new) + assert generated["status"] == "prepared", generated + source = Path(text(new, "source")) + gate = source / "justfile" + _ = gate.write_text( + gate.read_text().replace( + ' @echo "ci: all gates passed"', + " ! git grep -n FORBIDDEN_REVIEW_MARKER -- '*.md'", + ) + ) + _ = (source / "README.md").write_text("FORBIDDEN_REVIEW_MARKER\n") + reuse: Record = {**new, "mode": "existing", "task": "reuse"} + prepared = workflow.dispatch("prepare", reuse) + assert prepared["status"] == "prepared", prepared + result = workflow.dispatch("verify", reuse) + assert result["status"] == "blocked", result + records = [load(p) for p in (tmp_path / "state/sample/reuse").glob("run-*/*.json")] + failed = [ + r + for r in records + if r.get("exit") == 1 and r.get("argv") == [text(object_value(plan, "tools"), "just"), "ci"] + ] + assert len(failed) == 1, records + assert "README.md:1:FORBIDDEN_REVIEW_MARKER" in Path(text(failed[0], "output")).read_text() + assert not (source / ".git/index").exists() diff --git a/automation/tests/unit/test_review_regressions.py b/automation/tests/unit/test_review_regressions.py new file mode 100644 index 0000000..ae6a672 --- /dev/null +++ b/automation/tests/unit/test_review_regressions.py @@ -0,0 +1,85 @@ +"""Public workflow regressions for confirmed review findings.""" + +from pathlib import Path + +import pytest + +from kickstarter_workflow.files import mappings +from kickstarter_workflow.process import Runner +from kickstarter_workflow.records import Record, object_value, text +from kickstarter_workflow.workflow import Workflow + + +@pytest.mark.parametrize( + "directory", + [ + "ordinary tests", + "$(touch sr01-shell-marker)", + "`touch sr01-backtick-marker`", + "literal 'quote' $x ; &", + ], +) +def test_mapped_test_paths_remain_literal(tmp_path: Path, plan: Record, directory: str) -> None: + target = f"product/{directory}/test_app.py" + mappings(plan)[1]["target"] = target + prepared = Workflow(tmp_path / "state").dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + workspace = Path(text(prepared, "workspace")) + runner = Runner(object_value(plan, "tools"), tmp_path / "gate", tmp_path / "cache") + runner.env["PYTHONPATH"] = str(workspace / "product") + junit = tmp_path / "tests.xml" + runner.env["PYTEST_ADDOPTS"] = "--junitxml=" + str(junit) + _ = runner.run(["just", "test"], workspace, allow_failure=True) + assert not (workspace / "sr01-shell-marker").exists() + assert not (workspace / "sr01-backtick-marker").exists() + assert runner.last_exit == 0, runner.last_output.read_text() + assert 'name="test_twice"' in junit.read_text() + assert (workspace / target).read_bytes() == ( + Path(text(plan, "source")) / "test_app.py" + ).read_bytes() + + +@pytest.mark.parametrize("directory", ["{{ 'literal' }}", "[", "]", "*", "?", "{a,b}", "a::b"]) +def test_recipe_and_selector_syntax_is_refused_before_generation( + tmp_path: Path, plan: Record, directory: str +) -> None: + mappings(plan)[1]["target"] = f"product/{directory}/test_app.py" + result = Workflow(tmp_path / "state").dispatch("prepare", plan) + assert result["status"] == "blocked", result + assert not (tmp_path / "state/sample/one/workspace").exists() + assert not list((tmp_path / "state/sample/one").glob("run-*/*.log")) + + +@pytest.mark.parametrize("new", [False, True]) +def test_preparation_never_supplies_mapped_initializer_bytes( + tmp_path: Path, plan: Record, *, new: bool +) -> None: + source = Path(text(plan, "source")) + payload = (source / "app.py").read_bytes() + plan["mapping"] = [ + { + "source": "new_app.py" if new else "app.py", + "target": "product/__init__.py", + "role": "source", + "new": new, + }, + { + "source": "new_test.py" if new else "test_app.py", + "target": "product/test_app.py", + "role": "test", + "new": new, + }, + ] + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + workspace = Path(text(prepared, "workspace")) + if new: + assert not (workspace / "product/__init__.py").exists() + assert not (workspace / "product/test_app.py").exists() + assert workflow.dispatch("verify", plan)["status"] == "blocked" + _ = (workspace / "product/__init__.py").write_bytes(payload) + _ = (workspace / "product/test_app.py").write_bytes((source / "test_app.py").read_bytes()) + assert (workspace / "product/__init__.py").read_bytes() == payload + assert (source / "app.py").read_bytes() == payload + assert workflow.dispatch("resume", plan)["status"] == ("blocked" if new else "prepared") diff --git a/automation/tests/unit/test_safety.py b/automation/tests/unit/test_safety.py new file mode 100644 index 0000000..63913e3 --- /dev/null +++ b/automation/tests/unit/test_safety.py @@ -0,0 +1,214 @@ +"""Fail-closed safety and ownership contracts.""" + +from pathlib import Path + +import pytest + +from kickstarter_workflow.records import Record, Value, object_value, text +from kickstarter_workflow.workflow import Workflow + + +def test_external_records_never_modify_the_source_tree(plan: Record) -> None: + source = Path(text(plan, "source")) + workflow = Workflow(source / ".workflow-state") + before = sorted(path.relative_to(source).as_posix() for path in source.rglob("*")) + result = workflow.dispatch("prepare", plan) + assert result["status"] == "blocked", result + after = sorted(path.relative_to(source).as_posix() for path in source.rglob("*")) + assert after == before + + +def test_pending_state_write_blocks_resume_instead_of_reusing_old_readiness(tmp_path: Path) -> None: + workflow = Workflow(tmp_path / "state") + request: Record = { + "project": "sample", + "task": "one", + "mode": "disposable", + "reason": "diagnostic", + } + assert workflow.dispatch("prepare", request)["status"] == "exempt" + pending = tmp_path / "state/sample/one/task.pending" + _ = pending.write_text('{"status": "preparing"}') + result = workflow.dispatch("resume", request) + assert result["status"] == "blocked", result + assert "pending" in text(result, "reason") + assert pending.exists() + + +@pytest.mark.parametrize( + "revision", + [ + "main", + "v0.3.0", + "a" * 39, + "a" * 41, + "A" * 40, + "0" * 40, + " 37adbeeea53a242ed25db602a35b9d719d95c8ae", + ], +) +def test_unapproved_or_malformed_revisions_never_fall_back( + tmp_path: Path, plan: Record, revision: str +) -> None: + changed: Record = {**plan, "template": {**object_value(plan, "template"), "revision": revision}} + result = Workflow(tmp_path / "state").dispatch("prepare", changed) + assert result["status"] == "blocked", result + assert "template" in text(result, "reason") + assert not (tmp_path / "state/sample/one/workspace").exists() + + +@pytest.mark.parametrize( + "path", + [ + "../escape.py", + "/absolute.py", + "product/../../escape.py", + "product//app.py", + "product/./app.py", + "product/../app.py", + "product/\\\\app.py", + ".git/config", + "product/app.py\n", + ], +) +def test_unsafe_mapping_paths_are_rejected_before_generation( + tmp_path: Path, plan: Record, path: str +) -> None: + mapping: list[Value] = [ + {"source": "app.py", "target": path, "role": "source"}, + {"source": "test_app.py", "target": "product/test_app.py", "role": "test"}, + ] + result = Workflow(tmp_path / "state").dispatch("prepare", {**plan, "mapping": mapping}) + assert result["status"] == "blocked", result + assert "unsafe relative path" in text(result, "reason") + assert not (tmp_path / "state/sample/one/workspace").exists() + + +def test_symlinks_shared_state_and_missing_tools_are_refused(tmp_path: Path, plan: Record) -> None: + real = tmp_path / "real" + real.mkdir() + link = tmp_path / "link" + link.symlink_to(real, target_is_directory=True) + result = Workflow(link / "state").dispatch("prepare", plan) + assert "symlink" in text(result, "reason") + real.chmod(0o777) + result = Workflow(real).dispatch("prepare", plan) + assert "0700" in text(result, "reason") + tools = {**object_value(plan, "tools"), "copier": str(tmp_path / "absent-copier")} + result = Workflow(tmp_path / "good-state").dispatch("prepare", {**plan, "tools": tools}) + assert "missing executable" in text(result, "reason") + + +def test_exemption_promotion_and_unresolved_cleanup(tmp_path: Path, plan: Record) -> None: + workflow = Workflow(tmp_path / "state") + exempt: Record = {"project": "sample", "task": "one", "mode": "disposable", "reason": "one-off"} + assert workflow.dispatch("prepare", exempt)["status"] == "exempt" + assert workflow.dispatch("prepare", plan)["status"] == "prepared" + assert workflow.dispatch("resume", exempt)["status"] == "blocked" + assert workflow.dispatch("cleanup", plan)["status"] == "blocked" + assert (tmp_path / "state/sample/one/workspace").is_dir() + changed: Record = {**plan, "scope_review": "Different task intent."} + assert workflow.dispatch("prepare", changed)["status"] == "blocked" + assert list((tmp_path / "state/sample/one").glob("exemption-*.json")) + + +def test_generated_gate_weakening_and_failed_checks_remain_failures( + tmp_path: Path, plan: Record +) -> None: + workflow = Workflow(tmp_path / "state") + state = workflow.dispatch("prepare", plan) + workspace = Path(text(state, "workspace")) + config = workspace / "pyproject.toml" + original = config.read_text() + _ = config.write_text(original.replace("fail_under = 85", "fail_under = 1")) + result = workflow.dispatch("verify", plan) + assert result["status"] == "blocked" + assert "weakened" in text(result, "reason") + _ = config.write_text(original) + tests = workspace / "product/test_app.py" + _ = tests.write_text(tests.read_text().replace("== 6", "== 9")) + failed = workflow.dispatch("verify", plan) + assert failed["status"] == "blocked", failed + assert "just failed" in text(failed, "reason") + assert workflow.dispatch("status", plan)["status"] == "blocked" + + +def test_new_maintained_regression_paths_can_be_declared_before_creation( + tmp_path: Path, plan: Record +) -> None: + original_mapping = plan["mapping"] + assert isinstance(original_mapping, list) + request: Record = { + **plan, + "mapping": [ + *original_mapping, + { + "source": "tests/test_added.py", + "target": "product/tests/test_added.py", + "role": "test", + "new": True, + }, + ], + } + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", request) + assert prepared["status"] == "prepared", prepared + workspace = Path(text(prepared, "workspace")) + assert not (workspace / "product/tests/test_added.py").exists() + rejected = workflow.dispatch("verify", request) + assert rejected["status"] == "blocked", rejected + assert "missing product target" in text(rejected, "reason") + + +def test_resume_revalidates_workspace_instead_of_returning_stale_readiness( + tmp_path: Path, plan: Record +) -> None: + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + workspace = Path(text(prepared, "workspace")) + (workspace / ".copier-answers.yml").unlink() + result = workflow.dispatch("resume", plan) + assert result["status"] == "blocked", result + assert "workspace" in text(result, "reason") + + +def test_gate_bodies_cannot_be_replaced_by_success_markers(tmp_path: Path, plan: Record) -> None: + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + workspace = Path(text(prepared, "workspace")) + justfile = workspace / "justfile" + _ = justfile.write_text(justfile.read_text().replace("uv run ruff check .", "true")) + result = workflow.dispatch("verify", plan) + assert result["status"] == "blocked", result + assert "gate configuration" in text(result, "reason") + + +def test_missing_or_forged_receipt_is_not_success(tmp_path: Path, plan: Record) -> None: + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + request: Record = { + "project": "sample", + "task": "one", + "review": { + "receipt": "agent says passed", + "reviewer": "reviewer", + "reason": "test", + "allowlist": ["app.py"], + }, + } + result = workflow.dispatch("return", request) + assert result["status"] == "blocked" + assert "verification" in text(result, "reason") + + +def test_source_changes_are_not_certified_as_unchanged(tmp_path: Path, plan: Record) -> None: + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + source = Path(text(plan, "source")) + _ = (source / "app.py").write_text('"""Newer source belongs to another task."""\n') + result = workflow.dispatch("verify", plan) + assert result["status"] == "blocked", result + assert "source changed" in text(result, "reason") diff --git a/automation/tests/unit/test_transfer.py b/automation/tests/unit/test_transfer.py new file mode 100644 index 0000000..36f30a6 --- /dev/null +++ b/automation/tests/unit/test_transfer.py @@ -0,0 +1,147 @@ +"""Reviewed-return reconciliation and durable interruption regressions.""" + +import os +import signal +from pathlib import Path + +import pytest + +from kickstarter_workflow.records import Record, text +from kickstarter_workflow.workflow import Workflow + + +def review(receipt: str, *files: str) -> Record: + return { + "project": "sample", + "task": "one", + "review": { + "receipt": receipt, + "reviewer": "independent-test-review", + "reason": "Named product delta reviewed.", + "allowlist": list(files), + }, + } + + +@pytest.mark.parametrize("drift", ["content", "mode"]) +def test_return_refuses_unreviewed_payload_and_conflicting_newer_source( + tmp_path: Path, plan: Record, drift: str +) -> None: + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + workspace = Path(text(prepared, "workspace")) + source = Path(text(plan, "source")) + app = workspace / "product/app.py" + _ = app.write_text(app.read_text().replace("value * 2", "value + value")) + if drift == "mode": + app.chmod(0o600) + verified = workflow.dispatch("verify", plan) + assert verified["status"] == "verified", verified + receipt = text(verified, "receipt") + for request in ( + review("old-receipt", "app.py"), + review(receipt, "uv.lock"), + review(receipt, "app.py", "app.py"), + review(receipt, "test_app.py"), + ): + assert workflow.dispatch("return", request)["status"] == "blocked" + assert "value * 2" in (source / "app.py").read_text() + if drift == "content": + _ = (source / "app.py").write_text( + (source / "app.py").read_text().replace("value * 2", "value * 3") + ) + else: + (source / "app.py").chmod(0o640) + result = workflow.dispatch("return", review(receipt, "app.py")) + assert result["status"] == "blocked", result + assert "conflict" in text(result, "reason") + if drift == "content": + assert "value * 3" in (source / "app.py").read_text() + else: + assert (source / "app.py").stat().st_mode & 0o777 == 0o640 + + +def test_interrupted_return_reports_actual_effects_and_retry_does_not_rewrite_them( + tmp_path: Path, plan: Record, monkeypatch: pytest.MonkeyPatch +) -> None: + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + workspace = Path(text(prepared, "workspace")) + source = Path(text(plan, "source")) + app, tests = workspace / "product/app.py", workspace / "product/test_app.py" + _ = app.write_text(app.read_text().replace("value * 2", "value + value")) + _ = tests.write_text(tests.read_text().replace("test_twice", "test_double")) + verified = workflow.dispatch("verify", plan) + assert verified["status"] == "verified", verified + request = review(text(verified, "receipt"), "app.py", "test_app.py") + original = Path.replace + + def interrupt(path: Path, target: Path) -> Path: + result = original(path, target) + if target == source / "app.py": + os.kill(os.getpid(), signal.SIGINT) + return result + + with monkeypatch.context() as fault: + fault.setattr(Path, "replace", interrupt) + pid = os.fork() + if pid == 0: + _ = signal.signal(signal.SIGINT, signal.default_int_handler) + try: + _ = workflow.dispatch("return", request) + except KeyboardInterrupt: + os._exit(73) + os._exit(74) + _, status = os.waitpid(pid, 0) + assert os.waitstatus_to_exitcode(status) == 73 + observed = workflow.dispatch("status", plan) + assert observed["status"] == "returning", observed + assert observed["applied"] == ["app.py"] + before_retry = (source / "app.py").stat().st_mtime_ns + untouched = source / "test_app.py" + before_test = untouched.read_bytes() + _ = untouched.write_bytes(before_test + b"\n# A concurrent owner changed this file.\n") + refused = workflow.dispatch("return", request) + assert refused["status"] == "blocked", refused + assert "interrupted return" in text(refused, "reason") + assert (source / "app.py").stat().st_mtime_ns == before_retry + _ = untouched.write_bytes(before_test) + returned = workflow.dispatch("return", request) + assert returned["status"] == "returned", returned + assert returned["applied"] == ["app.py", "test_app.py"] + assert (source / "app.py").stat().st_mtime_ns == before_retry + assert "test_double" in (source / "test_app.py").read_text() + assert workflow.dispatch("cleanup", plan)["status"] == "cleaned" + assert not workspace.exists() + assert (tmp_path / "state/sample/one/return.json").is_file() + + +def test_compatible_three_way_merge_preserves_both_edits_and_exact_whitespace( + tmp_path: Path, plan: Record +) -> None: + source = Path(text(plan, "source")) + original = "\nHeading\n\nA\nB\nC\nD\nFooter\n\n" + _ = (source / "README.md").write_text(original) + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + workspace = Path(text(prepared, "workspace")) + _ = (workspace / "product/README.md").write_text(original.replace("Heading", "Task heading")) + verified = workflow.dispatch("verify", plan) + assert verified["status"] == "verified", verified + _ = (source / "README.md").write_text(original.replace("Footer", "Other footer")) + result = workflow.dispatch("return", review(text(verified, "receipt"), "README.md")) + assert result["status"] == "review_required", result + assert result["receipt"] != verified["receipt"] + assert (source / "README.md").read_text() == original.replace("Footer", "Other footer") + diff = Path(text(result, "reconciliation_diff")).read_text() + assert "Other footer" in diff + assert ( + workflow.dispatch("return", review(text(verified, "receipt"), "README.md"))["status"] + == "blocked" + ) + assert workflow.dispatch("resume", plan)["status"] == "review_required" + result = workflow.dispatch("return", review(text(result, "receipt"), "README.md")) + assert result["status"] == "returned", result + assert (source / "README.md").read_text() == "\nTask heading\n\nA\nB\nC\nD\nOther footer\n\n" + assert result["reconciled"] is True diff --git a/automation/tests/unit/test_workflow.py b/automation/tests/unit/test_workflow.py new file mode 100644 index 0000000..db36ea3 --- /dev/null +++ b/automation/tests/unit/test_workflow.py @@ -0,0 +1,156 @@ +"""Workflow behavior through its public request interface.""" + +import shutil +from pathlib import Path + +import pytest + +from kickstarter_workflow.records import Record, object_value, text +from kickstarter_workflow.workflow import Workflow + + +@pytest.mark.parametrize("project_type", ["application", "library"]) +def test_new_projects_generate_and_existing_projects_reuse_without_upgrade( + tmp_path: Path, plan: Record, project_type: str +) -> None: + new: Record = { + **plan, + "mode": "new", + "source": str(tmp_path / "new-project"), + "isolate_git": True, + } + new["answers"] = {**object_value(plan, "answers"), "project_type": project_type} + new["mapping"] = [ + {"source": "src/sample/__init__.py", "target": "src/sample/__init__.py", "role": "source"}, + { + "source": "tests/unit/test_example.py", + "target": "tests/unit/test_example.py", + "role": "test", + }, + ] + new["repository_checks"] = [["just", "ci"]] + workflow = Workflow(tmp_path / "state") + result = workflow.dispatch("prepare", new) + assert result["status"] == "prepared", result + assert result["workspace"] == new["source"] + workspace = Path(text(new, "source")) + answers = (workspace / ".copier-answers.yml").read_bytes() + _ = (workspace / "README.md").write_text("Existing project ownership.\n") + reuse: Record = {**new, "mode": "existing", "task": "reuse"} + reused = workflow.dispatch("prepare", reuse) + assert reused["status"] == "prepared", reused + isolated = Path(str(reused["workspace"])) + assert isolated != workspace + assert (isolated / ".copier-answers.yml").read_bytes() == answers + assert (isolated / "README.md").read_text() == "Existing project ownership.\n" + assert (workspace / "README.md").read_text() == "Existing project ownership.\n" + + +def test_workbench_copies_product_and_retains_real_copier_provenance( + tmp_path: Path, plan: Record +) -> None: + workflow = Workflow(tmp_path / "state") + result = workflow.dispatch("prepare", plan) + assert result["status"] == "prepared", result + workspace = Path(str(result["workspace"])) + source = Path(text(plan, "source")) + assert (workspace / "product/app.py").read_bytes() == (source / "app.py").read_bytes() + answers = (workspace / ".copier-answers.yml").read_text() + assert "_commit:" in answers + assert result["template_revision"] == "37adbeeea53a242ed25db602a35b9d719d95c8ae" + assert not (workspace / ".git/hooks").exists() + assert not (source / "pyproject.toml").exists() + assert workflow.dispatch("resume", plan)["workspace"] == str(workspace) + other = workflow.dispatch("prepare", {**plan, "task": "two"}) + assert other["status"] == "prepared", other + assert other["workspace"] != result["workspace"] + + +def test_verify_runs_real_template_repository_and_product_gates( + tmp_path: Path, plan: Record +) -> None: + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + verified = workflow.dispatch("verify", plan) + assert verified["status"] == "verified", verified + assert int(str(verified["product_tests"])) >= 1 + assert verified["product_coverage"] == 100 + receipt = verified["receipt"] + assert workflow.dispatch("status", plan)["receipt"] == receipt + workspace = Path(str(prepared["workspace"])) + _ = (workspace / "product/app.py").write_text('raise RuntimeError("candidate changed")\n') + stale = workflow.dispatch("status", plan) + assert stale["status"] == "blocked" + assert "stale" in text(stale, "reason") + + +def test_reviewed_return_preserves_unrelated_files_and_validates_destination( + tmp_path: Path, plan: Record +) -> None: + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + workspace = Path(str(prepared["workspace"])) + source = Path(text(plan, "source")) + app = workspace / "product/app.py" + _ = app.write_text(app.read_text().replace("value * 2", "value + value")) + verified = workflow.dispatch("verify", plan) + assert verified["status"] == "verified", verified + _ = (source / "unrelated.txt").write_text("Other task owns this.\n") + request: Record = { + "project": "sample", + "task": "one", + "review": { + "receipt": verified["receipt"], + "reviewer": "independent-test-review", + "allowlist": ["app.py"], + "reason": "Equivalent expression reviewed.", + }, + } + result = workflow.dispatch("return", request) + assert result["status"] == "returned", result + assert result["destination_validated"] is True + assert "value + value" in (source / "app.py").read_text() + assert (source / "unrelated.txt").read_text() == "Other task owns this.\n" + assert not (source / "pyproject.toml").exists() + assert result["reconciled"] is True + + +def test_receipt_is_invalidated_when_the_executed_interpreter_changes( + tmp_path: Path, plan: Record +) -> None: + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", plan) + assert prepared["status"] == "prepared", prepared + verified = workflow.dispatch("verify", plan) + assert verified["status"] == "verified", verified + interpreter = Path(text(prepared, "workspace")) / ".venv/bin/python" + assert interpreter.is_symlink() + original = interpreter.readlink() + alternate = tmp_path / "alternate-python" + _ = shutil.copy2(interpreter.resolve(), alternate) + try: + interpreter.unlink() + interpreter.symlink_to(alternate) + stale = workflow.dispatch("status", plan) + assert stale["status"] == "blocked", stale + assert "stale" in text(stale, "reason") + finally: + interpreter.unlink() + interpreter.symlink_to(original) + assert workflow.dispatch("status", plan)["status"] == "verified" + + +def test_disposable_exemption_is_durable_but_not_verification(tmp_path: Path) -> None: + workflow = Workflow(tmp_path / "state") + request: Record = { + "project": "sample", + "task": "one", + "mode": "disposable", + "reason": "one-off diagnostic", + } + result = workflow.dispatch("prepare", request) + assert result["status"] == "exempt" + assert workflow.dispatch("resume", request)["status"] == "exempt" + assert workflow.dispatch("verify", request)["status"] == "blocked" + assert workflow.dispatch("return", request)["status"] == "blocked" diff --git a/automation/uv.lock b/automation/uv.lock new file mode 100644 index 0000000..46cd5d6 --- /dev/null +++ b/automation/uv.lock @@ -0,0 +1,1813 @@ +version = 1 +revision = 5 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "basedpyright" +version = "1.39.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/43/ad2999f3b09eb2b1e59931d88fac0f7bcc9c17fc18c903268779bd10cc97/basedpyright-1.39.10.tar.gz", hash = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e", size = 24740347, upload-time = "2026-08-13T17:09:02.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/2a/a224054d75a58786c482f63b8ff2a09fc3362268bd1ebd0a61fb3f982153/basedpyright-1.39.10-py3-none-any.whl", hash = "sha256:cbd75d83c0be841329bcfef2d2f1182f152a6d975b8eb199e75cf5b8e9a3de78", size = 13482322, upload-time = "2026-08-13T17:08:59.074Z" }, +] + +[[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 = "2026.10.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/67/6a0b94a7960d5e1b5eacd2fb529f3fccc47db4644f7f0a7cfdcfc3be578a/charset_normalizer-3.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6", size = 370642, upload-time = "2026-09-30T04:35:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/fb/94/01009e13b94041599004edf32e56e382c24e570f60f79bab8efe45cfe1eb/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5", size = 259280, upload-time = "2026-09-30T04:35:08.448Z" }, + { url = "https://files.pythonhosted.org/packages/66/85/3b5358f60a13210f0b67d3755c168ef758701b021e655d88d4da28554467/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74", size = 245335, upload-time = "2026-09-30T04:35:10.104Z" }, + { url = "https://files.pythonhosted.org/packages/74/75/77c1c479b09ecd751d1e767b251ea5c14d4d50ff757bf404afab2692f600/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab", size = 291707, upload-time = "2026-09-30T04:35:11.575Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0d/363f78cacb70f58f15f4b083961bbd9d292f335d3f5c66fc4f1cfe69cb90/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c", size = 286747, upload-time = "2026-09-30T04:35:13.022Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ed/cf505d3011ffceb12c2067a7a5d3cfe92b875d4d44bb0ff0d69375e2c184/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f", size = 269972, upload-time = "2026-09-30T04:35:14.606Z" }, + { url = "https://files.pythonhosted.org/packages/15/d8/f0a93a431d170e7ca681d4f6650fee3de934d18560e474e7267eb4b0f987/charset_normalizer-3.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288", size = 269339, upload-time = "2026-09-30T04:35:16.087Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/9b2bd1c5b7af02462c9752d33994834ff972a96b4c483eefde9e594488e2/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400", size = 261683, upload-time = "2026-09-30T04:35:17.488Z" }, + { url = "https://files.pythonhosted.org/packages/76/a5/cac540ab0fd61f3fec88ad3dbb64509e71424593d73cfdfff5ab3e4db279/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd", size = 248143, upload-time = "2026-09-30T04:35:18.849Z" }, + { url = "https://files.pythonhosted.org/packages/71/7a/ff467301deef2089fad87f72df9e000a26a78fec7acbb18e1999371b8369/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37", size = 292358, upload-time = "2026-09-30T04:35:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/ad/77/22d7e785d1e210afc2e2f58600dd1799d17a35665faf84383f002826c5f8/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac", size = 268766, upload-time = "2026-09-30T04:35:21.72Z" }, + { url = "https://files.pythonhosted.org/packages/ae/91/e8e946267f1c2d9e2bd651726e2fbd2addf02c4d36cea5069e32ca9d7bb5/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a", size = 287432, upload-time = "2026-09-30T04:35:23.273Z" }, + { url = "https://files.pythonhosted.org/packages/4e/88/7561d8a88d555e7df6623abe7c0070b4baf47549b9408783a2ae0a1a6cf7/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640", size = 272470, upload-time = "2026-09-30T04:35:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/35/7e/578c702301ec036f01455f30744a08d2b42f6ab35b9b2d4bf8cae0ef2a80/charset_normalizer-3.5.2-cp311-cp311-win32.whl", hash = "sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d", size = 186688, upload-time = "2026-09-30T04:35:26.225Z" }, + { url = "https://files.pythonhosted.org/packages/e8/fc/fdf8cf52ff21cd5bf158f20978991cf985325842f74283eb6df26c8a39d8/charset_normalizer-3.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96", size = 214932, upload-time = "2026-09-30T04:35:27.796Z" }, + { url = "https://files.pythonhosted.org/packages/97/66/3e45a506d8110b632541faf9a9470185aa9878f1ed44020f31346c1c5e5b/charset_normalizer-3.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1", size = 202828, upload-time = "2026-09-30T04:35:29.259Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, + { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, + { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, + { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, + { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, + { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, + { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, + { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, + { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, + { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, + { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, + { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +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 = "copier" +version = "9.15.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "dunamai" }, + { name = "funcy" }, + { name = "jinja2" }, + { name = "jinja2-ansible-filters" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "plumbum" }, + { name = "pydantic" }, + { name = "pygments" }, + { name = "pyyaml" }, + { name = "questionary" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/8f/c0e30c11a9c0e0bd6158a1ba33190f3faf2e3659da1295f8817ccab9002b/copier-9.15.2.tar.gz", hash = "sha256:58d07c62a3426c4f5b8c482c8cda59646de53733388536427356a8625116050c", size = 640695, upload-time = "2026-06-12T08:54:12.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/7d/733c088265e01e653c70e89e58345a3dd346acc34af26987314b84b9ffaa/copier-9.15.2-py3-none-any.whl", hash = "sha256:50a39142d6493f2261762309374a7a7462dbab654c5d22fd9aa17b6a69b84a95", size = 63389, upload-time = "2026-06-12T08:54:10.991Z" }, +] + +[[package]] +name = "coverage" +version = "7.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/fa/ce3baf63d85b730398d92a7162f486f3a5e4e2cc3382a02488b3943725ba/coverage-7.16.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:732d950e51f3ba4fb6209c73250f3e8924fefca42953ee04a9e65d8c02414d7d", size = 223797, upload-time = "2026-09-27T12:25:54.756Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/9ba29c2aac7f756d479f03d45762120060f0f988788001001bf36e0e6fca/coverage-7.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5dca0bb66b4c3d624ba047887bf70270030c150692d543cb501293dc38a9f4b5", size = 224303, upload-time = "2026-09-27T12:25:56.214Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/0d6d60906dca7d28cc1e3fce12a9861801c4fbb6cbf220ad78cd059c9467/coverage-7.16.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:af2a2a8c7c74de0559e0c368d94c8def9e16c58faaee33a0bf081057c4227e3b", size = 254715, upload-time = "2026-09-27T12:25:57.755Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b8/9198b865679379fb165c689c64f6e11105ef380f6bd1c7673e83f73d9f5c/coverage-7.16.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db5f8394e17f877a625b257f2ba0ce8e728a499c2c1579ad66220272cd3df510", size = 256627, upload-time = "2026-09-27T12:25:59.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/79/9521462cb6072fe394701bc8974b74afd576c9c9355156c7844e1a86a42b/coverage-7.16.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3146d2317c75f70df2509066d979dadd941f7021cdf9b5db4bcd8568258e25", size = 258724, upload-time = "2026-09-27T12:26:00.691Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/8d7d5d633db9fe0f3182fedc731bf09f9bcf2366055735152504ad614677/coverage-7.16.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e1d0ced76318bab499693ff25f64faa343415187cb2e4d7befdfdd391a1cf6a", size = 260692, upload-time = "2026-09-27T12:26:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a7/76cb09c89ba46d74d37428bf93251fc14fb0bbe9e05cc2a5ef61773d318a/coverage-7.16.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:af98ad5ed9d6daaca956201e00bb429a7eb2b080426686f70a20353e0f9839f5", size = 254800, upload-time = "2026-09-27T12:26:03.369Z" }, + { url = "https://files.pythonhosted.org/packages/72/b6/2351c1979aaeb5b4a8091a75b90ca997ad60de36e181ddba267cf61dac97/coverage-7.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d56e4d21c56d2046447733f8b118409597db48c01efe898ee9ac24e858ec2d6", size = 256423, upload-time = "2026-09-27T12:26:04.751Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f4/ad9a4f8b5cb2d494fa9452b546fe742ed2f9d3847cc14c05e36279a3e649/coverage-7.16.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1d5d0e3b660506fb84f995814e3118a21efdc0c8eb80127da1be627d90093c17", size = 254487, upload-time = "2026-09-27T12:26:06.082Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1f/a520470472f3e8b01169bf42162b1470c9ba992230432f62ca36269bf3a0/coverage-7.16.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:17228fbca0f22976f797be94e975dcd237799c657d49551c7de1e0654d1202e9", size = 258259, upload-time = "2026-09-27T12:26:07.513Z" }, + { url = "https://files.pythonhosted.org/packages/09/d2/ff26d5938274745855fa61cfcba0245c88ccc10d98d2cbd96064f16cd5a7/coverage-7.16.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:bc0b0ac781d489304b741269857f1f8338b7a26b1b89c06c0344658001ec0035", size = 254216, upload-time = "2026-09-27T12:26:08.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/5d832d3b06785d9f53267e4f2724a9f60c312eee6ebed9063a461d0d3b45/coverage-7.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bf1bd822ec4e387ed245bed0d71151582cf7be9e5309bc4145eefe36083d5878", size = 255125, upload-time = "2026-09-27T12:26:10.35Z" }, + { url = "https://files.pythonhosted.org/packages/55/4d/1d33edbc2fcf7d99e384e393e712aa5a2ebbbd8409825357815982207976/coverage-7.16.2-cp311-cp311-win32.whl", hash = "sha256:7ed238d227e23cc300c3d464babdaf9f6ddc740aa1b15a77ae96136e6a7c4516", size = 225898, upload-time = "2026-09-27T12:26:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7c/676df4882118756c4f8f560c954eddb93e166d84dda8c5f0b6a829689bde/coverage-7.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:a90700f743e29aa3d75a6ff5f01953176a889c00e526194bc4d281731b88d99d", size = 226369, upload-time = "2026-09-27T12:26:13.375Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0e/a457f4a461b3c5610d845137fdd45fa465e011a64c25af440518ab1f4e41/coverage-7.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:a336eec40e3520d369b8a6cdabb4f596e69a8b42927ca074aa1452fed943238a", size = 226344, upload-time = "2026-09-27T12:26:15.127Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/f8296c63c5d542f3d21aed685e56b7031a419037d155bb3382fc0940d249/coverage-7.16.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:218d742afca2b5ad5ca759e93eddedfbcc6eadf8322f080dcefc40b7bd4e2d48", size = 223969, upload-time = "2026-09-27T12:26:16.753Z" }, + { url = "https://files.pythonhosted.org/packages/90/23/6f3dcb1423a0d43216e402ea1746e4a7c7c44f38896b97dd573790f56a40/coverage-7.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9a638be322a8d76a41cdb17781c7f82aaee6a66493d8ffb7e2c09ee22423d99", size = 224328, upload-time = "2026-09-27T12:26:18.15Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7d/8f3b6dc920e3fc6732f7678785a2091db439f186afbec30dbf2214d9b1f7/coverage-7.16.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:724bd0f1e81856b35e59fc98cf7b4e544a3cb662e4e0864dca73d4326ee9d808", size = 255832, upload-time = "2026-09-27T12:26:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/6c45f15be4eca4ac1062c6a55a323286494c99726a7e58951fe85967ac08/coverage-7.16.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d", size = 258571, upload-time = "2026-09-27T12:26:21.199Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/b54cbeba3ad89082c2e441278681859e538322cc34b84b2af7ebff00080f/coverage-7.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a076277ca9f5750cc230f0f578ebd2620cec60255b25707361699fef6fb465c", size = 259680, upload-time = "2026-09-27T12:26:22.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a2/0dc65ec3d61930e1e4c2e371763b15eb4290896eb343a12d5d3091308116/coverage-7.16.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58d4a54c6ea672afef66d49be922a2c69826c5ae1a42a9cd94f0c9c2bacdf800", size = 261945, upload-time = "2026-09-27T12:26:24.336Z" }, + { url = "https://files.pythonhosted.org/packages/d6/93/5fad7a61f2c14e08e98946fc31c1c7ffc1195061bf3fdc351db3be77a863/coverage-7.16.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcbcfcc059117284c603ff8cb61a65872512882f84a8cf0339241f7f7c2f148", size = 256191, upload-time = "2026-09-27T12:26:25.89Z" }, + { url = "https://files.pythonhosted.org/packages/2d/47/74e5de9227b939ece9f64e729645ddc4296bea10dbfa98721c1333c8be2e/coverage-7.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afdf43b72ef3876c1fe66423b91466e37877c9e81e8cec70542b7e8525b9d1b7", size = 257610, upload-time = "2026-09-27T12:26:27.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/2cf28d40b43645d1b72388fe3ee7f7c747533a6a9557bb8c24a7ae74fe1a/coverage-7.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9acc7f7ec4a1b5f89bd929fde5b8a714f6fafdc6cc18725413d510aa082b47ad", size = 255757, upload-time = "2026-09-27T12:26:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3d/7c149fd99fc8bbc39c80db5e688d1d39fd040be2ecb78b8335a51a55b9c0/coverage-7.16.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:80d3f7b48d43ee8fc5e8707a8adb43d743a5a1a85256c25a24f9d6d0e2238fa6", size = 259822, upload-time = "2026-09-27T12:26:30.515Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/b283fce09d5995e227bd8e513358dd7471bedc0f78abc85a925ebdb0a2f6/coverage-7.16.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:126d1af8804d7224421fe991ff65d3ce649081560df7a98b1a5ffff07f9923bd", size = 255325, upload-time = "2026-09-27T12:26:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/bf/91/f3325edf0c4223fb1fe1532b8dbef2a1d2f729459a9a7d1a44d073bae534/coverage-7.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c19cd6d025c1673f22afcd22c7df8a662d779e05d8e3fa6820c22afb895b0206", size = 257191, upload-time = "2026-09-27T12:26:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/89/21eb5e83ecf2eed523c4eb3d65ae513cd082c8fd1b6deb34c4cb6c332f97/coverage-7.16.2-cp312-cp312-win32.whl", hash = "sha256:152877cdc8a07264882cfcd503ba56a3ef6cba56a70e8c70f6eb8ffd7384789a", size = 226034, upload-time = "2026-09-27T12:26:35.021Z" }, + { url = "https://files.pythonhosted.org/packages/db/de/e3ad6d864c0833624b4f1f9b53f9e58e116c945e5e965c3f1e172c5e84cd/coverage-7.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:e6c52d3307824ff93b39efd99e4185d557db40bd841452abfb32e5d9151ca162", size = 226569, upload-time = "2026-09-27T12:26:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c1/bccc58ebe5489cc70628f635c1932fd371f5d7da850dbcf960f95f4c4afc/coverage-7.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:a678c0b6b22086ec2427359d22e37445d4a792f5fdbbc744112c7dade65cad02", size = 226372, upload-time = "2026-09-27T12:26:38.406Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f6/8eb4f220ef24f84fb27d852d4f9bf83e0c73ec1a4a08dd9a87e3f4529739/coverage-7.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9", size = 223993, upload-time = "2026-09-27T12:26:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/40/23/d4bbaf0c154e0b0c2b5264890dbf6ef098dcb50ec8f2469be9490d191660/coverage-7.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b", size = 224368, upload-time = "2026-09-27T12:26:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/7f/48/fc1e88fd571ec5cb38150b7f89f7696ca1bdf9920e01432febb69774cc85/coverage-7.16.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204", size = 255358, upload-time = "2026-09-27T12:26:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/1d/56/6785397d07c29c8e70fbb9a07e97d062b43c21ffc5f12385917847f09f63/coverage-7.16.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5", size = 257955, upload-time = "2026-09-27T12:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/27/3b/c8cdd07721e5f99abd81cea970d971997f99bf158c0b85f51bd284179c8b/coverage-7.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7", size = 259183, upload-time = "2026-09-27T12:26:47.208Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/606b192fe43d32574ec6238549d48de588fdcc18485682a5ec0a8ac357f2/coverage-7.16.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541", size = 261325, upload-time = "2026-09-27T12:26:49.084Z" }, + { url = "https://files.pythonhosted.org/packages/67/90/eea481f8b0305ceeb33f081a5f47e298391dbd1b589de0c4b3b3aa50d3f2/coverage-7.16.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc", size = 255531, upload-time = "2026-09-27T12:26:50.509Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/dedbf9aea1457b120c27ac10b8fc2a357f37fa2b54c3e7286d42980a0a2a/coverage-7.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967", size = 257329, upload-time = "2026-09-27T12:26:52.005Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cb/b25c19d5bb2bd0f2e4e27fe8e2ffcae80c7a91ae181c0dc749ed60e9b1a4/coverage-7.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7", size = 255294, upload-time = "2026-09-27T12:26:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a2/892c5c5f4ad44b7b2ca009aee705191f3f268f15052244f2f9e3539b2e35/coverage-7.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23", size = 259444, upload-time = "2026-09-27T12:26:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/ed/99/a562537deba0a3e370182ae71c149be796c39d8087365f17a09188f27145/coverage-7.16.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8", size = 255111, upload-time = "2026-09-27T12:26:56.851Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/854ec68641a9b3362ff068a32dfa41637299761617ef253791dbade6fc76/coverage-7.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4", size = 256882, upload-time = "2026-09-27T12:26:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/db/0d/748e4518b0ac0f9ff2687c248a6e5f8c0737306e709372632a2556f84443/coverage-7.16.2-cp313-cp313-win32.whl", hash = "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865", size = 226045, upload-time = "2026-09-27T12:26:59.983Z" }, + { url = "https://files.pythonhosted.org/packages/31/fa/6e46edba66a183fe4d99d4bb52c173287e9b8dddabe0888d24cb8210e580/coverage-7.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6", size = 226581, upload-time = "2026-09-27T12:27:01.494Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d9/9ef6845367600b336ff75d000444a0d32497d6972c833141bd39356abf68/coverage-7.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79", size = 226409, upload-time = "2026-09-27T12:27:03.116Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/577fc0803dab4155dcf808faffbdd7b159256781c0874a8586e17b81b149/coverage-7.16.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a", size = 224138, upload-time = "2026-09-27T12:27:05.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/9e/e3785ba3ecba2bd11efc74bfe2801ca4b78c4480b15a375648d809a59da3/coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee", size = 224445, upload-time = "2026-09-27T12:27:06.805Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/963ff22d3fd27117da3b8cc442f5bdc91196f783321e1a8ff0ec43476772/coverage-7.16.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b", size = 256112, upload-time = "2026-09-27T12:27:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d4/a306940c81c6ae759e82fff27d20b7fdc6896e422b821f51313cce212b6c/coverage-7.16.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03", size = 258727, upload-time = "2026-09-27T12:27:09.927Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a3/d3d99d93b02517087aa05bc0cf2d04d372956b849e5443e059079901429b/coverage-7.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda", size = 259909, upload-time = "2026-09-27T12:27:11.55Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/39dd599181726758dd185ae4dc0c0ab3aeabf7ca70e68e145060feeaaa16/coverage-7.16.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c", size = 262481, upload-time = "2026-09-27T12:27:13.17Z" }, + { url = "https://files.pythonhosted.org/packages/99/e8/91ee43f6ded411460c359d7e1aebde4d6fd8f00a2e5394182d9d212eb23c/coverage-7.16.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9", size = 256012, upload-time = "2026-09-27T12:27:14.91Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/e9499ddc33197bd7eabcb1118ca81756fc874457b324e2b479a4804b2ad2/coverage-7.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4", size = 257898, upload-time = "2026-09-27T12:27:16.588Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6e/c081cb5991a0afba99f9c4ad6c74a5fce9513a38ddc64e3e6680c6fed9af/coverage-7.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90", size = 255938, upload-time = "2026-09-27T12:27:18.19Z" }, + { url = "https://files.pythonhosted.org/packages/b2/42/1c3d819e8f9b6eb01c2fe90874d67a8882adb9507e0bbb09361ed131ea89/coverage-7.16.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95", size = 260368, upload-time = "2026-09-27T12:27:19.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/4f/d70eac07901fd587b6ab05e659b52afe13959992aa5113bf6cce059cc572/coverage-7.16.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8", size = 255620, upload-time = "2026-09-27T12:27:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/6d87af88317d3d9a9b18a9ca1bc1673eb516917f296e579d0d4a55cb3490/coverage-7.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6", size = 257521, upload-time = "2026-09-27T12:27:23.358Z" }, + { url = "https://files.pythonhosted.org/packages/79/bb/90c2641170d2fa1a6757b3f8450ba2740197317b0ddd749e9604b914e886/coverage-7.16.2-cp314-cp314-win32.whl", hash = "sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011", size = 226248, upload-time = "2026-09-27T12:27:25.153Z" }, + { url = "https://files.pythonhosted.org/packages/30/08/d8d0478bb02c8eb0ae20a496fc80c40fcf4d3450bd184300d682ba2d28a6/coverage-7.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b", size = 226732, upload-time = "2026-09-27T12:27:26.907Z" }, + { url = "https://files.pythonhosted.org/packages/32/3f/0001da22155b0a8ce063ec0f7e64ecbe17b373f306e7a74435f6d6accb72/coverage-7.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb", size = 226645, upload-time = "2026-09-27T12:27:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/d7/85/6d8813aff9b8b8586691a9d33c43c5604f7227622574da7cdc3d91a86861/coverage-7.16.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf", size = 224871, upload-time = "2026-09-27T12:27:30.32Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/444f3a4981ac2cda40fdcf4cc9b56a4e1a33c222abeb33e51ed3e3eb2a6b/coverage-7.16.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036", size = 225123, upload-time = "2026-09-27T12:27:32.33Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c1/980681cd7b33eb66ac835044116ef0a92e11fcc7bdd866cc89d10b1130b9/coverage-7.16.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3", size = 265049, upload-time = "2026-09-27T12:27:34.085Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e3/87679875c33bb2191f0f05544a1cc9adcc940fe0c35443a10f2df753dde5/coverage-7.16.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831", size = 267717, upload-time = "2026-09-27T12:27:36.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/64/5d372776d6eb523d4e93bafba2253f96984e3b18261c4cc56a50863c6d0d/coverage-7.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf", size = 270047, upload-time = "2026-09-27T12:27:37.96Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/44082ff0cbf9f97d0043f57970a71204097ec7ba606361a9fd2065393669/coverage-7.16.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681", size = 271360, upload-time = "2026-09-27T12:27:39.766Z" }, + { url = "https://files.pythonhosted.org/packages/b8/17/9a215efe25b5e0ecc87c89dbe525c4a87d14d87c8c0c7316ef140a5f6f3e/coverage-7.16.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0", size = 264736, upload-time = "2026-09-27T12:27:42.072Z" }, + { url = "https://files.pythonhosted.org/packages/a2/da/7f0a31af8e448107d4d32844bd684757f51ea907bc0c68c8fd537b2123ff/coverage-7.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb", size = 267600, upload-time = "2026-09-27T12:27:43.85Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/3bfecbd3366b775bacdcb3330394d356cf384b5d8f5b2146ac4b14b252b5/coverage-7.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7", size = 264767, upload-time = "2026-09-27T12:27:45.768Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3f/5d62163732d87e4a0c4710a0eab30f0fd6a2d480112abe2029f014fe8c9d/coverage-7.16.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64", size = 269026, upload-time = "2026-09-27T12:27:47.787Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/8e4579f225426535085a9be371cc75e3b026d058d679b80affbdfb4c3ef0/coverage-7.16.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20", size = 264147, upload-time = "2026-09-27T12:27:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/ef1f77e2c3f7bb03c2b13b9a2006f88700fdd75535ef158d70049f425c1c/coverage-7.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce", size = 266376, upload-time = "2026-09-27T12:27:51.551Z" }, + { url = "https://files.pythonhosted.org/packages/be/79/0cb2bf4428830dec971c718c2c841a039c084415c99e67281f5a72841aab/coverage-7.16.2-cp314-cp314t-win32.whl", hash = "sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46", size = 226585, upload-time = "2026-09-27T12:27:53.945Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/da17121c16667fd84998e972200ae226a41540f6ea4795776c6d99e8976f/coverage-7.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c", size = 227378, upload-time = "2026-09-27T12:27:55.778Z" }, + { url = "https://files.pythonhosted.org/packages/74/89/01179c62d1b7e6e33bd5001566b02d7f778cf33d3ec1e81e94ca170c517f/coverage-7.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91", size = 227064, upload-time = "2026-09-27T12:27:57.496Z" }, + { url = "https://files.pythonhosted.org/packages/4c/57/52935003c3f627ba6e5203d7179aad32448c10899663a30336aba8e81a2c/coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8", size = 224140, upload-time = "2026-09-27T12:27:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24", size = 224449, upload-time = "2026-09-27T12:28:01.299Z" }, + { url = "https://files.pythonhosted.org/packages/0c/aa/3be084d5b82e63ccdad4ed751e4acbae294673573e30481d29f8b7402eec/coverage-7.16.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09", size = 256060, upload-time = "2026-09-27T12:28:03.185Z" }, + { url = "https://files.pythonhosted.org/packages/de/29/48fca82a7ebf7ff7b2e35019cc9537e7f65e4d2aa1215cc5a8792c989251/coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843", size = 259106, upload-time = "2026-09-27T12:28:05.15Z" }, + { url = "https://files.pythonhosted.org/packages/06/3d/b2d5986f2dd53fe201aa1be2e4ab204fa1aed5101e67c0dbbb419b850aee/coverage-7.16.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1", size = 260674, upload-time = "2026-09-27T12:28:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7e/b50160be3506ead12e6480d14279af7f0f17627694300a2d1fd2c42d2ff5/coverage-7.16.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982", size = 263139, upload-time = "2026-09-27T12:28:08.771Z" }, + { url = "https://files.pythonhosted.org/packages/14/5e/7c805ac9a32606de1399bd7e9bd375aa2f973dc61b12680d9e6403c2e891/coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34", size = 256591, upload-time = "2026-09-27T12:28:10.842Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9e/76f1ed129a2daf658a3ea17122824cf2e3b91fea0460d8d3664fc5a61018/coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6", size = 258708, upload-time = "2026-09-27T12:28:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b7/8d62e75f48b527619239a65294f842d4b7fd02a0839d43ae1de80184e2df/coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4", size = 256562, upload-time = "2026-09-27T12:28:14.934Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8d/0a15f95c3afb78e947c52644786ba4bc9de259905687dd720d5e6fae2e76/coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0", size = 261077, upload-time = "2026-09-27T12:28:17.215Z" }, + { url = "https://files.pythonhosted.org/packages/25/00/88389987305a47d732866c07c8a500000ab574df9505e3114ac69c8d027f/coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a", size = 256034, upload-time = "2026-09-27T12:28:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/92/02/34d079d4952ad461bde037d353f9a6e037a7edc45fe0f9ee8781ff73f028/coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63", size = 258034, upload-time = "2026-09-27T12:28:21.242Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d8/3e59a62879285b464ec1b10fd824fbc1af9ce66e842cd39974f80a0becc4/coverage-7.16.2-cp315-cp315-win32.whl", hash = "sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e", size = 226252, upload-time = "2026-09-27T12:28:23.102Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e1/128026e1b2836e9ad6b219207ba9edf1c5e0088a7869e23088aee7fbbe7a/coverage-7.16.2-cp315-cp315-win_amd64.whl", hash = "sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae", size = 226728, upload-time = "2026-09-27T12:28:25.21Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/c9fa8e7cf525ca7748ac52b0ee89331d13fe09808e45c679830708782e90/coverage-7.16.2-cp315-cp315-win_arm64.whl", hash = "sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495", size = 226648, upload-time = "2026-09-27T12:28:27.136Z" }, + { url = "https://files.pythonhosted.org/packages/a2/13/e96b045447a856666f36f9c653e2a80bdaa732aaaf72412b19aa2c26a473/coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75", size = 224858, upload-time = "2026-09-27T12:28:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/23/90/087f6ad1bd3df059632ca3407a4e6552ed1053ee35354de0a771acf35423/coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55", size = 225139, upload-time = "2026-09-27T12:28:31.131Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8e/285dcef0184358044e7cbcd810a1bdc9566bc620f54702d605477155df4a/coverage-7.16.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2", size = 265090, upload-time = "2026-09-27T12:28:33.04Z" }, + { url = "https://files.pythonhosted.org/packages/06/b2/cc83f3a6e5789a4e89059c69555bc641c2efcde568405a1c06fc702951ab/coverage-7.16.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2", size = 268254, upload-time = "2026-09-27T12:28:35.135Z" }, + { url = "https://files.pythonhosted.org/packages/ac/41/f548c19530f5d66ac6e3c92bbcbc49da7261de3a458b9f3e54a3efb1a0b2/coverage-7.16.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1", size = 270695, upload-time = "2026-09-27T12:28:36.959Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/4dc27cf82ef96434d2874110ad0cc10ea4621025705dc5049862bd3bd181/coverage-7.16.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f", size = 271853, upload-time = "2026-09-27T12:28:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/38/29/bf8072b1b8bd5f2de8b21460a404460b1a2b97e80a9464c78ec0271f6199/coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d", size = 265644, upload-time = "2026-09-27T12:28:40.815Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2f/0aecb8721be5cdeb8afd9d6d9f6b463f074e4d8d37f00f4c42442522709f/coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0", size = 268530, upload-time = "2026-09-27T12:28:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0b/92b4b7628268ee711249958e68fc0328779bd3d9a7ab4715379465aedb84/coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283", size = 265154, upload-time = "2026-09-27T12:28:44.684Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/41c95c1ab29b3dcd357cd1227181d1c98185632aca41ce670ce671b23a43/coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec", size = 269799, upload-time = "2026-09-27T12:28:46.672Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ebeb259aa5362b033a137b86d7274ff4b109d59be8cc9913889b783bf75a/coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5", size = 265196, upload-time = "2026-09-27T12:28:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/b2/18/8437620f90d023680a072eee02f968055f3658bbfb7d386d0ea34cfb7f30/coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c", size = 267266, upload-time = "2026-09-27T12:28:51.361Z" }, + { url = "https://files.pythonhosted.org/packages/28/6c/f08e8ee4293e6434035424180bef4d45e028e8ecc006c61bf9453e74405e/coverage-7.16.2-cp315-cp315t-win32.whl", hash = "sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35", size = 226583, upload-time = "2026-09-27T12:28:53.33Z" }, + { url = "https://files.pythonhosted.org/packages/f7/fd/3f939c2847f4a72c20cff8b1ac33da78ea91a2d38d9b43336e60db719103/coverage-7.16.2-cp315-cp315t-win_amd64.whl", hash = "sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5", size = 227374, upload-time = "2026-09-27T12:28:55.158Z" }, + { url = "https://files.pythonhosted.org/packages/5a/35/b98cdc354c952402132e675a87f2cc3227fb68f959c84aaa491fbe15933d/coverage-7.16.2-cp315-cp315t-win_arm64.whl", hash = "sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8", size = 227064, upload-time = "2026-09-27T12:28:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cyclonedx-python-lib" +version = "11.12.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/17/40/6509e6cfd7f2f3255501690f46375fdd224949e21fd1e96f4f4c8a9041b1/cyclonedx_python_lib-11.12.0.tar.gz", hash = "sha256:16767c4039de90c04e9f03348f8f0ed4b8ff842eaa7eefcad3a95685f970dacf", size = 1445378, upload-time = "2026-08-13T07:52:18.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/f0/b2cb999244f2f4194df63ecff6939228178fa63a33be809c412684ca8db7/cyclonedx_python_lib-11.12.0-py3-none-any.whl", hash = "sha256:0e807521a921a5c3cb8ce1153f8a61d29eedfe76a46aac2796b7c6b573391a54", size = 529453, upload-time = "2026-08-13T07:52:16.836Z" }, +] + +[[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 = "distlib" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/d36966de7304047a1001a7559fbf266c464bb5d8cc85464536ef8c9bcc32/distlib-0.4.4.tar.gz", hash = "sha256:ca4175b4ac7f71223cc30ed7248c45d2b38c2439d8ee6e498ab67ed63cf3508d", size = 621304, upload-time = "2026-10-16T13:56:34.203Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/2b/238f02a4927fa2e27aa995371d240a468b52e42eae44a678434aff736b38/distlib-0.4.4-py2.py3-none-any.whl", hash = "sha256:cf09d55b1e85a89576b036aeb71550bfd68320f3f21761ca329eadadad918638", size = 472099, upload-time = "2026-10-16T13:56:32.053Z" }, +] + +[[package]] +name = "dunamai" +version = "1.26.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/62/edf5352d8ce1371129ccfe971e6de3a1ce4dc7f9bdc80390fb79856b78d4/dunamai-1.26.3.tar.gz", hash = "sha256:fe2f732186710ee5048044fdbef6c76c6afbb9ee7de4f6d00cf34256e9ec2e7f", size = 46820, upload-time = "2026-10-14T20:13:30.921Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/f4/e3be26f6301535ab4a3a940b09e55615c6286195b07fec9aa2cf8b842563/dunamai-1.26.3-py3-none-any.whl", hash = "sha256:cc486963f21ad5b5b20d95424909ff5029990241eea3e2272865e1542c5fd19f", size = 27628, upload-time = "2026-10-14T20:13:29.974Z" }, +] + +[[package]] +name = "filelock" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/a9/1af41b37c3279712b22cdc63aac78a52432202b6fe1f9666a2a3d2831fb4/filelock-4.2.0.tar.gz", hash = "sha256:7a60906c75227cf04d0c273afadc8219400f11aeb13cc69591d4f6cdc6c8036e", size = 569667, upload-time = "2026-10-14T20:57:13.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/a3/9bc26acff301fe1aaea1cc3d82a1d57e0a34df3e1cadbfa91ac2dbcdde5c/filelock-4.2.0-py3-none-any.whl", hash = "sha256:2ff5690882e8cdb00ef31fb3d01a3094c29f30985426c59495afb1733f3b7238", size = 135032, upload-time = "2026-10-14T20:57:11.349Z" }, +] + +[[package]] +name = "funcy" +version = "2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/2c/c704bc76973088cb17b663a467aab7e2d46a999d647116db145daa2121ed/funcy-2.1.tar.gz", hash = "sha256:8979feb3de7120afd1d00e5a8eae810c1942e4bfbc91697151e9ee1e49f79284", size = 981169, upload-time = "2026-09-10T07:11:55.896Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/5b/02e0c095105b0c8a26f122d1ec312731008b914814d172dd3ee81e851642/funcy-2.1-py3-none-any.whl", hash = "sha256:290794186530c944f7e444d2e28b98f33a55685f2bfb9b7a61dac564decab8f8", size = 29313, upload-time = "2026-09-10T07:11:53.902Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.170.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/d3/2bef2a3b89c78ad4ff47be6825f16063a3fa3b72d20a08df72d98c0940f6/hypothesis-6.170.1.tar.gz", hash = "sha256:e7426d21b4897ddd91f361458d4a8c8a7fa9b2eafe17d0db0311b3f17a093b17", size = 517771, upload-time = "2026-10-16T16:46:41.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/9c/a115246da13d5e755d770fa15b23df1b261f67b395d3bfec720d927d971d/hypothesis-6.170.1-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:122592adc790761f36ee7c4cae5d0f6dc9bcdb4fbc67da831182344c000ff8bb", size = 796247, upload-time = "2026-10-16T16:45:13.006Z" }, + { url = "https://files.pythonhosted.org/packages/ec/66/ade205f12e2b2c3db7ce0e941968664f2bd65b29f12fd259d6ea73365cd7/hypothesis-6.170.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:474db1373058df30b3645ed0558b500d4f7ece7635178ea3910e4558ef188fcd", size = 791265, upload-time = "2026-10-16T16:46:18.62Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b6/7a4d0aafcc893a4c2a4247cc3e29dc2c2c3e6775542c80c30bec9f21f0f5/hypothesis-6.170.1-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c0c2135c977cbae2a8612d8d6d3468b4de21eaf8b5b2461200fd008421c8053", size = 1120651, upload-time = "2026-10-16T16:45:38.165Z" }, + { url = "https://files.pythonhosted.org/packages/d4/55/9daa1b3115c77649fcf76c883d39effbea6a2f0af58f4fffaf2afe1fea44/hypothesis-6.170.1-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2ce83bda98f8e440a72160a7e88cf1e1a56dffe13257674634e286ac3dd1c08a", size = 1150854, upload-time = "2026-10-16T16:45:44.208Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6b/a2e2997d860c06a6e7803d5f20d3ef93de67d35033bfe74176989e42a7a2/hypothesis-6.170.1-cp311-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:145ed887aecddb256c9411d25732667205d799a7d7a1684433837a83f85bb98d", size = 1145331, upload-time = "2026-10-16T16:45:27.552Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9f/6e53db08d49a74fd0865995a0acdc808763f5c025c61120f16fc44ca6235/hypothesis-6.170.1-cp311-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d832f8977d8b568586c3078ca9700855b21a590e4c558d71d84fb47512175431", size = 1197621, upload-time = "2026-10-16T16:45:35.599Z" }, + { url = "https://files.pythonhosted.org/packages/05/36/022df2550afc46a22f4f7137647179cbb4f2a550f446e944875fba04c8ab/hypothesis-6.170.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8193b1cdc1d5dd264d958497f4ee9911714586e1df4e60e3f03a9af2112656e", size = 1163837, upload-time = "2026-10-16T16:46:06.751Z" }, + { url = "https://files.pythonhosted.org/packages/14/66/9c0352e2b28f8c73cda5344c939f3cb925eb95024ecc218242e7fa5327a2/hypothesis-6.170.1-cp311-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:0029ebe0254225c6ed09e8c9af72615a8aead8b9e0b9dbfe090f900091128bbe", size = 1119381, upload-time = "2026-10-16T16:45:56.351Z" }, + { url = "https://files.pythonhosted.org/packages/7b/74/a2557964d805f6a80ef926c770934a3b6565942952b269387186d385e8ad/hypothesis-6.170.1-cp311-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:aa85e14fcf88d4cb9dec429cb633feb1ac8e94f1901ff661349405b8e679c5c6", size = 1158348, upload-time = "2026-10-16T16:46:12.466Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e6/88eccf92070b33ff27baad86e5e5cb2932b313eef1c45bde602764e3f47e/hypothesis-6.170.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:94ada8c2ffb27e792811248be916b839549d0f165f67d5f29f81947ad2388397", size = 1295806, upload-time = "2026-10-16T16:45:39.558Z" }, + { url = "https://files.pythonhosted.org/packages/39/e1/80ee32552bafb939b8fc6b1cbf790bc9fdb90b735adc989a6fc7aece26aa/hypothesis-6.170.1-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:756c1a14d853ea1598aec96bc20d62fed9b0ccf79946121cfe64a8c1e213c671", size = 1424006, upload-time = "2026-10-16T16:45:19.628Z" }, + { url = "https://files.pythonhosted.org/packages/57/60/3b4b8750ccb3c96a2ddc228abb73f51c644d965f3a6a0c05169922578a4a/hypothesis-6.170.1-cp311-abi3-musllinux_1_2_i686.whl", hash = "sha256:da45db70cd60f74524400c2cd91260c6a5afdfa66f751eaa2a7ea9bafeb80dce", size = 1377945, upload-time = "2026-10-16T16:46:31.61Z" }, + { url = "https://files.pythonhosted.org/packages/42/96/e4e839edcb227ed78b1c4d26f9bf01040352ae55ae0d77e4493e3d6e142f/hypothesis-6.170.1-cp311-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:fc288207cc92193f7dad5f780da99b8c1d5d099d168acb13bda32f209a3d90df", size = 1274723, upload-time = "2026-10-16T16:46:26.91Z" }, + { url = "https://files.pythonhosted.org/packages/22/4e/252337aa93d3b515d224421c448a51e725e40a4bf1f42e86b857d4811222/hypothesis-6.170.1-cp311-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:20be39e51b82aefbf18046db1eee980bc42c4ef51acd0d354b3ae692665bca49", size = 1289763, upload-time = "2026-10-16T16:46:28.162Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/4c52fa5a7604e3fb4a020d14b29c972d45b03077cc79cb7542ae371c7e11/hypothesis-6.170.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cfdffc5ebd986725fe551f3431980d77dd149bf622619af9b8e7da78dbdf3a38", size = 1329579, upload-time = "2026-10-16T16:45:57.523Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0b/31057f1a48f1d1cfc39c09597002a0f07730650333fa4b9d6fb2ed4002a1/hypothesis-6.170.1-cp311-abi3-win32.whl", hash = "sha256:4fbfaa122cd4e451f83f8bf86f02fda6029355869084bcbc944cb9afa406f632", size = 683527, upload-time = "2026-10-16T16:46:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c3/ead384b2b9d3c264b3335ac369f4d52cdc7a3f6cfe7191a2347069c3ba56/hypothesis-6.170.1-cp311-abi3-win_amd64.whl", hash = "sha256:b632f06092224a909543e4037d4f55c791ff1afdcbb31c1b6b283e9a22435361", size = 690046, upload-time = "2026-10-16T16:46:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/34/fc/2add46cca2014fecedefb8516a2da0687805c8cd24527ca10f6f96782c83/hypothesis-6.170.1-cp311-abi3-win_arm64.whl", hash = "sha256:8feadd37ea8aebb73be6439bf9cfb939de3019b68c9589d5bef17661fa032de4", size = 688177, upload-time = "2026-10-16T16:46:34.267Z" }, + { url = "https://files.pythonhosted.org/packages/75/94/de58f444ddd944907c7a73d92cbbbab8a3c0c15d14f3e5280a661351971f/hypothesis-6.170.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:66e97cf9b1eab169b9f896a03219d9906219a2dfad0d4f0e03674476f311597a", size = 796871, upload-time = "2026-10-16T16:45:23.876Z" }, + { url = "https://files.pythonhosted.org/packages/c0/33/b9d354ae660ef21ae82516a4ee31967d368a758203f4c9b0a7686c8941fe/hypothesis-6.170.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:42d4c4941d525d11a40343544066c3aed62fff1b1c5ca7273276c8d3f765cfe9", size = 792474, upload-time = "2026-10-16T16:45:11.793Z" }, + { url = "https://files.pythonhosted.org/packages/7f/e6/074c84e46e224fdd49a5d04cdc6f2190914f0d2e17b821fa949a0a2d2b61/hypothesis-6.170.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c15cefed05f7f7a682c3fd6d33b1667491ab6fb66289e77d204685e3db6661a", size = 1121414, upload-time = "2026-10-16T16:45:31.831Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a6/bb92b4e03d0bdf8d4c161d51b80df8696d984070ae59327d05265c703374/hypothesis-6.170.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13018543d7b34be035a0c23d81f00b72ac2e95c9c85794dcbfed47fa6a1c7615", size = 1164165, upload-time = "2026-10-16T16:46:17.448Z" }, + { url = "https://files.pythonhosted.org/packages/52/c5/db47c5348003fff16f7d7ffead6c88a52a675d23949ee067fe1c9d1971c8/hypothesis-6.170.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:06eb369864d57f62a949ffa5e739a633fd04d6fec5720007fa2a2bcada3fb3e1", size = 1296203, upload-time = "2026-10-16T16:46:21.433Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ce/019f029e5f8ef1f5964cd913fdc29dfcba99a230ed56701d64706e778d01/hypothesis-6.170.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23c5902e448c88170da7e41106f5385afaf8212296a0d8bdfcdda7120c94ac67", size = 1330188, upload-time = "2026-10-16T16:45:04.418Z" }, + { url = "https://files.pythonhosted.org/packages/52/9e/7ccb8e55d2cebc08213ac46aad1a76ac6f56dbf50b842e845823805735b6/hypothesis-6.170.1-cp311-cp311-win_amd64.whl", hash = "sha256:45da111e2f8f8e34bf3652dd9b4bbc2aebe0403089c56e96f99c3a5895d17840", size = 690148, upload-time = "2026-10-16T16:45:10.648Z" }, + { url = "https://files.pythonhosted.org/packages/9a/72/ce86e042f5ac3a7d85f961a7bb36922d0caec90e5eeaaf072c8793b39519/hypothesis-6.170.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:eee2d7bf96ede692b694da1a7b038f4744e592be5add40bf1a2817feb9c72354", size = 797998, upload-time = "2026-10-16T16:45:58.7Z" }, + { url = "https://files.pythonhosted.org/packages/06/ef/d14503cc184f2ce5debdc8d26756b493acf0d4d3a291bc52133cee35351f/hypothesis-6.170.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61fdcf062bd0e6524195f1722164918e4cb02cea16d15afa0fe1ba9bf03bbe16", size = 789832, upload-time = "2026-10-16T16:45:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/10/e2/b8ad5a3fb31945345394cf0a1797efb15aa676cf7f5b539a51ae50226d79/hypothesis-6.170.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6377456a522fb23784f56aa9f93c1b660cefdbecf642a91c730fd688adb0fa40", size = 1119772, upload-time = "2026-10-16T16:46:39.238Z" }, + { url = "https://files.pythonhosted.org/packages/18/44/89bd94325a725339cea8ca64a7eb03ca071f7dc71264a4771d83c3e1a441/hypothesis-6.170.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6694f6f8b22d1d37b501e9300a2d47d0c46549180ded39ec636f14873bae4d40", size = 1163455, upload-time = "2026-10-16T16:46:05.547Z" }, + { url = "https://files.pythonhosted.org/packages/16/dc/f88331b877faba0fabcc25a88c3884e5dd19e09c90493442481f70d7bce7/hypothesis-6.170.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8cf4a2e5c162a5bcbfa468c9466b2f63730a20648c2b26587d8226b371badd92", size = 1294739, upload-time = "2026-10-16T16:45:46.604Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d0/d3a9c30eac0cfcab198cdf98bfe510e916a8702ff2caabd3c9072400d6c9/hypothesis-6.170.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:985d005a54061620047b9bf7d93b9380d0bcff5f7fb7601b98efb81541923363", size = 1329114, upload-time = "2026-10-16T16:45:22.343Z" }, + { url = "https://files.pythonhosted.org/packages/b9/20/a23584f13fd78d9f03db718d8d5d3ad6464c07e09d0c2691d29e1215561d/hypothesis-6.170.1-cp312-cp312-win_amd64.whl", hash = "sha256:9847ca75cfd3f7b216fce3f0249462f4546856ab80b374b3b924475923d30a40", size = 687480, upload-time = "2026-10-16T16:45:43.103Z" }, + { url = "https://files.pythonhosted.org/packages/22/3b/6974c00046ced59045ef97ca1343e6b6f882457589a2f74307cb2d80a94e/hypothesis-6.170.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:26547b7c21d6d79ccb9bc5e0049ac1d658271e4a51d46939649ee1a5c9b05a25", size = 797906, upload-time = "2026-10-16T16:45:55.24Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ba/e389f3ac83570fe4e84ff2f68c2296621151949f2fa2f44d9c047d7acf1d/hypothesis-6.170.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be07bf11adcf7b37536a4c893b63d34af95e358966de1232a5711c0193b04a59", size = 789736, upload-time = "2026-10-16T16:45:36.888Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b4/9ae75c3a63a87ca454383c6305f33f25538cf1eccf29491ba768b80e894d/hypothesis-6.170.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f10b19eff9daff21e8d07552de36e9f447009994946a223bc32da21cd0dbd196", size = 1119654, upload-time = "2026-10-16T16:45:53.958Z" }, + { url = "https://files.pythonhosted.org/packages/cf/40/eed6741ee50c5687c951acff0edd5f95f42722bad81eedfaedb52f8e2de2/hypothesis-6.170.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:804f35f16c2232c800e4755d74704f1168a10d2ceed176e08361f6ccccd235f0", size = 1163373, upload-time = "2026-10-16T16:45:24.981Z" }, + { url = "https://files.pythonhosted.org/packages/95/d4/b3f46422f9da6945cb706272091596df054170bcb5beba3da1580819c15a/hypothesis-6.170.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:80a6eac4b7d255f2ee30c8e430b0f76e3073cd6598672ad4184eee0992ec9b4e", size = 1294677, upload-time = "2026-10-16T16:45:20.8Z" }, + { url = "https://files.pythonhosted.org/packages/9e/74/5e182142a8fda719b830facd025bc667adc1feb39bc2475e16d4618abee8/hypothesis-6.170.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4374786217336d3fa330a05f0ae5bca6b3c44faff5da0fab1dec4ea5d65a92db", size = 1329106, upload-time = "2026-10-16T16:45:18.335Z" }, + { url = "https://files.pythonhosted.org/packages/87/07/3e8b050087c4b14f3585704c339e8fcb078485ad6879c1413193ecbb5685/hypothesis-6.170.1-cp313-cp313-win_amd64.whl", hash = "sha256:267a6bab1d4d1507436aa1b29c6a6c803bc7b9319a53d05ab22d3333931b23e9", size = 687450, upload-time = "2026-10-16T16:46:02.329Z" }, + { url = "https://files.pythonhosted.org/packages/a0/54/73a8bf77a3d9b9176fcc89dc81401cee323cae83b3ddba9a87a606859a66/hypothesis-6.170.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4192f0950603f1c68f57888244b2a23141050f747db8dc67e76ca7e98f959ce3", size = 798117, upload-time = "2026-10-16T16:46:03.947Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c1/f6441724a57c5b92f71451b243b682c016cbbfc728ba792154ecd7d00c6d/hypothesis-6.170.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:988061b6a7e4ddf124ca5bd52ab45cf0bfe93915c09dcce40e3e2e00b9cab682", size = 789849, upload-time = "2026-10-16T16:46:16.098Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2d/4e06cae907df9b6dfea73f3497ae132b865d2e957a6df242baa4b33ec728/hypothesis-6.170.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:321522b7cc2872acebd65161bfeed4ef86a7034a64f049b072eac7e79502598f", size = 1120268, upload-time = "2026-10-16T16:45:33.134Z" }, + { url = "https://files.pythonhosted.org/packages/85/41/4d999c28cad9f236f5a9a155b40c917ea32e3d961915c03b2b3ab8d120f0/hypothesis-6.170.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d68243fc960e2a5f202ac978ee45b2a7e92d41b9e4c1bca59a5948a1c922700", size = 1163565, upload-time = "2026-10-16T16:46:40.416Z" }, + { url = "https://files.pythonhosted.org/packages/18/2d/4ed29d7ed8c571e5203b535173f31381b07bcacdc0bb70814505add3a620/hypothesis-6.170.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9f647b5f42d3238773f2388234b62ece817379d26b03d71e05a164224c9a636c", size = 1294808, upload-time = "2026-10-16T16:46:32.769Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fc/c861382058fe04ba4b9c419e004e73691c4c59650f1b82d0aac51909e39b/hypothesis-6.170.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:64ee5038df772afeb08274f309151d3637eb70dd10e7d2ca48278f9b5217d427", size = 1329365, upload-time = "2026-10-16T16:46:24.232Z" }, + { url = "https://files.pythonhosted.org/packages/99/09/13c1e8db6776180e9ded6d130a667f7d2d7137e6a0786c12cbc941069529/hypothesis-6.170.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1a2dabb1d6c353754f6389405a22213df7515bbeaeeb5906c36c2e82e445b9f7", size = 630196, upload-time = "2026-10-16T16:46:29.288Z" }, + { url = "https://files.pythonhosted.org/packages/80/95/afb2447413dff5987def91b5cf4ecfaea5ef93a78f2ff29c1a809109db5b/hypothesis-6.170.1-cp314-cp314-win_amd64.whl", hash = "sha256:ae4e46d1a5b6172d1f77d7ba3c44a05a9a8e54c27541c0730c1cf4987481a685", size = 687241, upload-time = "2026-10-16T16:46:10.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/21/fa2eea4eefd29b8fdfe1ce3cd2c114929e9462e18e59be5189d795e93123/hypothesis-6.170.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:053b9caf23fa67c794e4e97bb5e1227a2df4458ea7c90df6709b32526da34d1c", size = 796354, upload-time = "2026-10-16T16:46:09.172Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2c/428857fb3cc015d704072e72c7c87e7a475a941b60aceb9681738f0fc512/hypothesis-6.170.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a916623adb9e44d4e4299b8bdfcfe372e3817632f0c1598a22a7e3bd42aa554b", size = 788590, upload-time = "2026-10-16T16:45:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/ec/57/d02c165727dafd2eb2224a5743eb22f209553d8e5e3a103bc50c53fb7df0/hypothesis-6.170.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e35a7e2b2781b79024abebbaf54d766d88728c309141982c48e382b1aaeef3b5", size = 1118090, upload-time = "2026-10-16T16:45:26.354Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d0/40feb6ed0ec06a5302fc20b5258312c6037dfc1b58289e86de3a9aae2c09/hypothesis-6.170.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1cf7690f49ae7922ee492a25bea6c23848a7f9a6e13613e9f90d1deebe9dc259", size = 1162483, upload-time = "2026-10-16T16:46:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/63/3f/e2a94566e571e1ae2a78dcf2814ea783625c9ce4cb34527dc40d8c268c14/hypothesis-6.170.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9ad1b2e74a3eb8c8812477cc6c907d0c3c4c8f92d9b50a5ec12d666d33c537a2", size = 1292996, upload-time = "2026-10-16T16:46:14.865Z" }, + { url = "https://files.pythonhosted.org/packages/76/bb/bdcbdc8a43afe7d1ad39eb788119bae4297d21abf5a094c89594c2ec73b3/hypothesis-6.170.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b25578a64b07c3f551709549e150f2ca7ad756a94378508822c6389cd07b7ae6", size = 1328138, upload-time = "2026-10-16T16:46:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/c8/1c68abc45e1497738066275ad3f117f3e0c76f6d6a389968d41563c369d4/hypothesis-6.170.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f6f783c7124e96f64fc4bdf899dee651758088c2328bf0129f6614c63e273d34", size = 687368, upload-time = "2026-10-16T16:45:50.477Z" }, + { url = "https://files.pythonhosted.org/packages/fa/55/facd7d9a131898e2c1989b581af7a44eb72a0393c7942eab8828849d0ba8/hypothesis-6.170.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:301e5df534b8d15d57e0e396006ad86be0dbd88f51f869a3748ec71d7f65cbe8", size = 795802, upload-time = "2026-10-16T16:45:42.04Z" }, + { url = "https://files.pythonhosted.org/packages/51/a5/3dbe2297dfac2db1898bd10e4e1ea9d15d248d5daa6b502af807db1341c7/hypothesis-6.170.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:f0d4353e2333e3e502853830c07733b0fa952f65b27b4ef58ac7f3e1ca09079a", size = 788198, upload-time = "2026-10-16T16:45:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7f/5e758034aedcac442d59db2d7402d4e6c559f1d44d24ba027de85ee9175c/hypothesis-6.170.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a006851707ea7515b62c5a03e23296bf09e34112f0cb5a3ae9f30859cdbb4c8", size = 1117345, upload-time = "2026-10-16T16:45:48.025Z" }, + { url = "https://files.pythonhosted.org/packages/8a/79/79c361cc7a2712497a0fb7d5a6146a9af29e997359dfa08953994247d178/hypothesis-6.170.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:17879d8552e229c63d90a030bd532f39d0a83cbb627972cd2b1844aaa202a4ab", size = 1148724, upload-time = "2026-10-16T16:45:09.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/02/585628234d37215b8c049b264860a566ec504794f5c754c9702886976e21/hypothesis-6.170.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65ee2eef76cccaa51a1365829a9b7337a79d722c294ff56ae0affe702178bf20", size = 1141876, upload-time = "2026-10-16T16:46:13.687Z" }, + { url = "https://files.pythonhosted.org/packages/99/32/50a5b9650c9191606a45b121fb357663d374f58e36fa9e9eba3c741a4a30/hypothesis-6.170.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a174a2e035e6482a8c16a4d91e4cc5f6dcdebd8863975f3d8fe05530c34892e", size = 1195564, upload-time = "2026-10-16T16:46:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/82/76/2d1843a620fc5cd171631ffd14b635b96491d09aed986c8cd5d3e02bfeaf/hypothesis-6.170.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9cc3a4ef49014897d3a3919bb5728f347f89ba4cb495057fed6d24b6b9fdff0", size = 1161692, upload-time = "2026-10-16T16:45:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/39/50/eb675e8f6a22999c49fa4e8cedbae6bf24b803d5a3169b4359d822822e2b/hypothesis-6.170.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:dcc6b2f974a9658ea66c33cbc9536b8d8a00a52ff9c302a0850d1153a86b0522", size = 1115890, upload-time = "2026-10-16T16:45:01.592Z" }, + { url = "https://files.pythonhosted.org/packages/19/3d/7f1998c05d92efcfa83dfc9cf6cf95313c901dd84e52c148fb361cfd0ec9/hypothesis-6.170.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cd35b966e21ae0097ef216c1e7762c973ad55bb25f8f852a0e622621aec3e131", size = 1154500, upload-time = "2026-10-16T16:46:38.114Z" }, + { url = "https://files.pythonhosted.org/packages/8b/99/e93dfbad66fca201dbe595a1e689e249ac59cd98af5fa52e0d9b983b65a6/hypothesis-6.170.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:9c1b478fde40e9f4fdd46667c800c523944db73aa7be69f0596f405f433afd36", size = 1292013, upload-time = "2026-10-16T16:45:16.956Z" }, + { url = "https://files.pythonhosted.org/packages/e8/7e/958a7704d7f6ef8e89461d384f907a05378f393a03852af5610ac6aec367/hypothesis-6.170.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3da1275beae2a1b55fa22898c64f48d1bf5f9c4989210bc55ba7db616415c4ce", size = 1421447, upload-time = "2026-10-16T16:45:34.321Z" }, + { url = "https://files.pythonhosted.org/packages/ae/34/80a6daac238db0962f546c1395f8834727243452dabbb9d87dfa45ff848a/hypothesis-6.170.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:b69f76bf1bbbeadc960e43b286e8968cc5c442e346e5b1d393487c05f53e9f7a", size = 1374589, upload-time = "2026-10-16T16:45:07.182Z" }, + { url = "https://files.pythonhosted.org/packages/cf/0b/b8aac18169de947ee86baa8318492ae604a508eac4dfa2b0d7b650ce9d7e/hypothesis-6.170.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:33dedc326fed1cc7c0c9516ce8242d10555548d758d0c33596e44ae0751d07a0", size = 1270418, upload-time = "2026-10-16T16:46:30.484Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/d26fc550837b23dc92f6ce3cf0b698856304f058c9e103983f9817b9315f/hypothesis-6.170.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:bdf190cf0f19b0d6a9fa8150d949fff75afefb0a6969690701e4d7fcace2d6a5", size = 1287803, upload-time = "2026-10-16T16:45:28.997Z" }, + { url = "https://files.pythonhosted.org/packages/c1/10/e1aa20beafb619acc77b488a8e78ebbf4b00ba4f256e6927b8b9cd1eaa7e/hypothesis-6.170.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:2e935a2e56ec685c4d77e4f3b5f8e15399e963affcd7a8294c387b9a631af73a", size = 1327442, upload-time = "2026-10-16T16:46:25.601Z" }, + { url = "https://files.pythonhosted.org/packages/99/af/c1eb767aa8af1caf0ddd717938d77c520dbceb5c1d5210b56abc5ca53de5/hypothesis-6.170.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:18ba438f706d361ea9ac2de7b769ca25881d4cf00e3feee2b891e9fe3de8ede3", size = 680752, upload-time = "2026-10-16T16:45:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b7/2964b8703406fcf117db1e002c14af08511384cd81c487c1d13dc835810d/hypothesis-6.170.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:6cb263b857da574791b97d7a7ef2c648508c42655808267483bdae97536a2c03", size = 687084, upload-time = "2026-10-16T16:46:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/23/7f/0e5f88c2bdb8fcfe24cd46365b3fa9ce2312138c3646fcb267cfc67631dd/hypothesis-6.170.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:a12e6f79d7ed5342f3e0165a30da04c32b986d03a187dad82c6f86382f968703", size = 685029, upload-time = "2026-10-16T16:45:30.418Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fc/861b66709a4b1ab10a7b1c14772c0fedf262470a0a03433509b3a9ef73a1/hypothesis-6.170.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b07e4522763e1e9ee0363eeb74b44eda27c1fbf538ebc1dea9583f0b8107be12", size = 796822, upload-time = "2026-10-16T16:46:11.231Z" }, + { url = "https://files.pythonhosted.org/packages/6e/3c/12a9a08f3741400181c338643153ec2f4082484f9d05bb0b2e3631df0e37/hypothesis-6.170.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:74ba044bee849ea5993b86606defee93b11b075270bbd3a63a16595c150f7a77", size = 791623, upload-time = "2026-10-16T16:45:52.793Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9d/cfaadd5962e6fde5864358706b5819168aa3b450c5e94a5a59ae1f8b17b3/hypothesis-6.170.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30934f50e0ed702d2587a678654429dfde2715ab3816cbc4fa98bc44bb9afd40", size = 1121082, upload-time = "2026-10-16T16:45:49.3Z" }, + { url = "https://files.pythonhosted.org/packages/50/b6/dd4e266792691c026db0dc8151269691e92b3ae6dd1c40033d111fa12371/hypothesis-6.170.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6641e78a823f6cd50cd608fb3fc1ba8addf8d24080380e09407551e8410bd6b3", size = 1164446, upload-time = "2026-10-16T16:45:51.62Z" }, + { url = "https://files.pythonhosted.org/packages/5c/29/b87c86fa72db067a09b83975e46c0878a87c2d9f5fc91466cfb9a95fa381/hypothesis-6.170.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:18a134b71da8088606aaa05ffc2c3914a63ccf79d46b3254dc79b5ef64558eee", size = 690643, upload-time = "2026-10-16T16:45:59.909Z" }, +] + +[[package]] +name = "identify" +version = "2.6.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/35/d70c0006c7cee65999ea94a6273e60b2094f600a3d8b71b04318253fc643/identify-2.6.20.tar.gz", hash = "sha256:ad729860a923858d26917c2f4fb0a1d83d27a75b1e090c06440c573f048f3285", size = 99681, upload-time = "2026-09-26T20:29:07.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl", hash = "sha256:6a16b69b93187244e0548cbfd25b3e4a6f9a7a2ad784625c3bec2b8d27b81aaa", size = 99506, upload-time = "2026-09-26T20:29:06.054Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[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 = "jinja2-ansible-filters" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/27/fa186af4b246eb869ffca8ffa42d92b05abaec08c99329e74d88b2c46ec7/jinja2-ansible-filters-1.3.2.tar.gz", hash = "sha256:07c10cf44d7073f4f01102ca12d9a2dc31b41d47e4c61ed92ef6a6d2669b356b", size = 16945, upload-time = "2022-06-30T14:08:50.775Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl", hash = "sha256:e1082f5564917649c76fed239117820610516ec10f87735d0338688800a55b34", size = 18975, upload-time = "2022-06-30T14:08:49.571Z" }, +] + +[[package]] +name = "kickstarter-workflow" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "basedpyright" }, + { name = "copier" }, + { name = "hypothesis" }, + { name = "mutmut" }, + { name = "pip-audit" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "uv" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "basedpyright", specifier = ">=1.39,<1.40" }, + { name = "copier", specifier = "==9.15.2" }, + { name = "hypothesis", specifier = ">=6" }, + { name = "mutmut", specifier = ">=3.6" }, + { name = "pip-audit", specifier = ">=2.10" }, + { name = "pre-commit", specifier = ">=4,<5" }, + { name = "pytest", specifier = ">=8,<10" }, + { name = "pytest-cov", specifier = ">=5,<8" }, + { name = "ruff", specifier = ">=0.17,<0.18" }, + { name = "uv", specifier = "==0.13.4" }, +] + +[[package]] +name = "libcst" +version = "1.9.0" +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/02/c0/098e5c91ff1537f00c85a6438b6cb1863d17144680cc91f47c87f104a200/libcst-1.9.0.tar.gz", hash = "sha256:087b58a9afe076bb08e2d726478e1f16cb928d67ffa9092817e033c335de522a", size = 914739, upload-time = "2026-07-29T21:28:43.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/36/b45e6950878d9143f95db609432031ac688b311aa14450a8df239c648c45/libcst-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3cdbafbc992b38ea005ef37e20243c4177879020c82d414789fbb7fa22d6d3a9", size = 2054911, upload-time = "2026-07-29T19:24:38.542Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d8/1c1ae22e05fbc763dc5a4e4c3cab356166913ef19d2c2da42e6f1be5c98c/libcst-1.9.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:3f735e3acb3afc75d50803fde1598b467bf05d12d6854025df4398aebd75a14c", size = 2209147, upload-time = "2026-07-29T19:24:40.039Z" }, + { url = "https://files.pythonhosted.org/packages/84/e1/0d5eaeac4dadd34fcd9ea154d0a6e5ce10c9fec0e02c73f807a16475b8b0/libcst-1.9.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:951f13c193e590fc88c9582a5341a130fe53ecece51043cff4bd54e25a5e022d", size = 2260355, upload-time = "2026-07-29T19:24:41.423Z" }, + { url = "https://files.pythonhosted.org/packages/71/b2/45ce33c6bca5c1961a90f2999138741daa0dc8cf6fe8b122c0ca2502632d/libcst-1.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc6aa3e7f21315dbe9868c0d13ffaac309514e8c816320c5f91230fa5b21df92", size = 2275659, upload-time = "2026-07-29T19:24:42.828Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b1/936d2f17d93fb160579ff56ca671d26154209bbc07ea8645eb11db1f9633/libcst-1.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f101e0bf8638eba80694ecc3c3abb5309eb488f5271427536cb379c5c1e0ba9e", size = 2383467, upload-time = "2026-07-29T19:24:44.397Z" }, + { url = "https://files.pythonhosted.org/packages/e7/59/db667f15d62cb22c75b24f6b13c4151292aa1889a64ff9abce92f60d5a97/libcst-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:77d9303572cf0fbd0a2eb71aff167a4246541f43bf58b781b35b9d0bbab80ddc", size = 2109985, upload-time = "2026-07-29T19:24:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a8/99926865cd3f97c0cffc71cd38152c883b7ae666eb8327d355f01bddb33a/libcst-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:4090105fa4ad114766d0f8b34418d9a3b359bc8645da860a447e19770b5705d1", size = 1988559, upload-time = "2026-07-29T19:24:47.528Z" }, + { url = "https://files.pythonhosted.org/packages/b0/bb/d22c37c33dfe18084634f5ef89f8f0749ffe7b6e0ad312722aafd86bbbdb/libcst-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cd1a3500c41784075c4946a995d5ad89f68fa0d226b63ff3c4d78f6ea6dd23e5", size = 2043159, upload-time = "2026-07-29T19:24:49.013Z" }, + { url = "https://files.pythonhosted.org/packages/10/b8/2dedef84d72e7271119217503b69ed6dc5d0b2077685e163caae669d9c70/libcst-1.9.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:611cebd3bbc2014576f4dcc7b845b3c594c96ddc287a3db9b78f22eff156a7d3", size = 2203245, upload-time = "2026-07-29T19:24:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/e8/90/e02ac2dad647423f947bb11f8322bfdba8ccfdd380e6c7b695add2d1acd4/libcst-1.9.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8d731abe1307720ea1a52d447555e8443a6d130e0e520243c0634a58f6edbc9d", size = 2255388, upload-time = "2026-07-29T19:24:52.21Z" }, + { url = "https://files.pythonhosted.org/packages/13/5f/6089a51518cfd2ff40950eb26bcc36951d7b6d4f4213568aa0290265aff7/libcst-1.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8bc5351d92ca6ac1cc32e097700e1161ad1ceaa4d9b2cca5abadb1e94576b325", size = 2268982, upload-time = "2026-07-29T19:24:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/ed/78/26881ec466fb70cbc129dca26ccb5a52a0061face2c5822e4b61f00f9699/libcst-1.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03165a264653bb77f6a11b412ae09c08bdb0c25864f3b8d42b816ac64b9d4b9e", size = 2378174, upload-time = "2026-07-29T19:24:55.175Z" }, + { url = "https://files.pythonhosted.org/packages/e1/7a/a4dba5f11faf12a12ffba06d18019987851aab33b590242a602ffd4fb1bd/libcst-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:b755ed4a4bc2faee849b54820023137d60d4c199e0a0822f0ff0c1bc49e49b48", size = 2103462, upload-time = "2026-07-29T19:24:56.966Z" }, + { url = "https://files.pythonhosted.org/packages/f5/13/57cb129093e0d6744b3c914ba6cbbdf51ed1b2c823882936c553a3a0cf1b/libcst-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e50576bad7d56459d9792cd0b0dfe5469dad13646e9a9c0a8b1ba20b269f332", size = 1979825, upload-time = "2026-07-29T19:24:58.403Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b1/befc0544283bb3923a928accf79ed685e5a725524bdb3491826670affc07/libcst-1.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b8b9df30f524317b097dc53065b25dda33d6a4cc3c7c8bf4fc83ca7559c58cc0", size = 2043754, upload-time = "2026-07-29T19:24:59.885Z" }, + { url = "https://files.pythonhosted.org/packages/45/50/fef7c172a8457c95894edf5fb04805024899cdfea41fa01b0636587b79e1/libcst-1.9.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e465a7bc9c2b9533eb9e06d2391f8819f811b5112919d4064c9fb8565aaafa08", size = 2203548, upload-time = "2026-07-29T19:25:01.323Z" }, + { url = "https://files.pythonhosted.org/packages/18/ff/764cd2be1fd99d774fc44039c319dc0ed1d9d9afeaa02759a05121cccd4b/libcst-1.9.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8504b422c95676a8c27b517e1ac01413ece91bf356865c587ca9bdcd5708a2f7", size = 2255274, upload-time = "2026-07-29T19:25:02.764Z" }, + { url = "https://files.pythonhosted.org/packages/34/a7/474748a27a02fa83e3556b260d5f5236ca48164fa7beffecf3b2cad24dca/libcst-1.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bcb9f9d4fcfe2ec7a40d2c26e03a538d5d9dc189c38551eb3f94ab661afee7c0", size = 2269126, upload-time = "2026-07-29T19:25:04.158Z" }, + { url = "https://files.pythonhosted.org/packages/8a/b7/655e45363b8cf87b91e41e060b21c89e5316c7ef36eb14a1a498b27bb71e/libcst-1.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8d671c39a431c309476099b8ec811e412503ec0f4465f6fc907cb51c70e8e6b", size = 2378602, upload-time = "2026-07-29T19:25:06.424Z" }, + { url = "https://files.pythonhosted.org/packages/db/13/6da63f0902ece43bf9d737017251ad7ad06edd9d3c4e1856450403cb4473/libcst-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:4d382fba04077eb556a1ea4295a4482e192aa93639c5a07ba0885841965ea0c0", size = 2103486, upload-time = "2026-07-29T19:25:07.979Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ff/dccb1a55e38b4e47256a97242d61d2bef5366c744faf88fb087d4fa0f995/libcst-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:a621e261990148c1cfbe26c1798bd2375c131cf358a44a7a4659fc41c1a336e1", size = 1979907, upload-time = "2026-07-29T19:25:09.532Z" }, + { url = "https://files.pythonhosted.org/packages/65/2a/4943c71d90975bc59034a057dea346b365c276f308c1d31f5cf2bd85492d/libcst-1.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eccf4c57d273cdd3fe1c67b72cf9bb1bbd4547aa011824e96ccf5b7136057aa4", size = 2044529, upload-time = "2026-07-29T19:25:11.04Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1168b98c2a0f338be3a44753647baab051feaf6167cc887bf4447f8fd920/libcst-1.9.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32395244edfe6538e0ea2bf82051d60103d3f54861274805c4fb3745efb70a85", size = 2203519, upload-time = "2026-07-29T19:25:12.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/7d/2eaa697a80f899bcf2245680bbfcc1e478eb3b3328c21d4b2880bdf00dac/libcst-1.9.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:444e84c76cd035cd2fe136838c1a524d34b08216521f6f5093df8a6f6cfa5799", size = 2255879, upload-time = "2026-07-29T19:25:14.137Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/793b9fd96dd52d202f5f2b88d7e54f05ebed767d1bb3b32395a1817bf6ab/libcst-1.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb5d0946f2b4c6711b5d69fe4f833b364f9e7a1a2b08f88b98619dc18975099a", size = 2269807, upload-time = "2026-07-29T19:25:15.647Z" }, + { url = "https://files.pythonhosted.org/packages/a3/f4/1bc7aaea03971c45e8a885fed9fc1c73176dbbd00b0296a3cde9529bafd6/libcst-1.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45808c03528b3ad40b14095348a918e08d98c47b4a125d631cb78e817c0a5b16", size = 2378171, upload-time = "2026-07-29T19:25:17.289Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f7/bc49e367d2bc8817213594dd52cf6b2da2dbbda90b5382d60653999274ac/libcst-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:568288cdbfe3b4ca3ae4852cb0a439ff053dd54c841bc4995bf2b71238b5de40", size = 2177847, upload-time = "2026-07-29T19:25:19.35Z" }, + { url = "https://files.pythonhosted.org/packages/87/80/4d81577a22e6d535d1a3409f3a1c6903e09036f0e17fc47f92169dbc3501/libcst-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:107593af46945593e7825821793393262bc4fa1d3ea24c3ed487b61269bbbdf8", size = 2058134, upload-time = "2026-07-29T19:25:20.769Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7f/c3f3a0e7a1a2adaa76e815e82a7814d6bfe7d49432276bba652248c68d0b/libcst-1.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f6248cb07444ab9a6733a855737a9febed8b9adca51347019348b09a3ac7dfe9", size = 2036102, upload-time = "2026-07-29T19:25:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/4e/af/2f5543255b2c7d749b966adeccc6bfb1652cf8b425afb913d0f3f025a865/libcst-1.9.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:496c24e0d3240bc7da45dae543aff3f5b6509978c39262d6b84ed2fb999dded2", size = 2194806, upload-time = "2026-07-29T19:25:24.017Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/03f4cddce426d005e208b39ea7b2d456e667cdee0f1891360f0fc8430f20/libcst-1.9.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ea490fa8540503db5f321f0268becab46eb50f710e8cec8041e241fd66f6874f", size = 2246762, upload-time = "2026-07-29T19:25:25.365Z" }, + { url = "https://files.pythonhosted.org/packages/d8/31/9d5fe1e43dc3dbcc74f70f3e0e73fcdd8d84effc1059d8b45974f0d0d2eb/libcst-1.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:50ab94bb2524b419056d4003032b8c67102ac800f8d85b3c4260a01746d94dbb", size = 2259633, upload-time = "2026-07-29T19:25:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2b/6752b28d88c3a19b3bc0b9e1838443b6760d5c862b4f4b37955402659e24/libcst-1.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a2faaf92500d0226358125630f5aab4758e8aad3f2d70a10892ec3c700781a54", size = 2368043, upload-time = "2026-07-29T19:25:28.344Z" }, + { url = "https://files.pythonhosted.org/packages/e2/94/775825b2637f8ab05694b6a4b3802ae6783b4e799f9b58d2400c7e2d4369/libcst-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0c7b548512db25af9c2997a95fa731bd6b6928ecbad6c0915d7482d8bb42d34f", size = 2176432, upload-time = "2026-07-29T19:25:29.921Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3d/88ad67427c6fd9db929e087912b0a540e5140e5cb77e7ca4170edaac8531/libcst-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:497d5329345f1f5df84e41b0bbd00204b64a2fd30dfe3cfaeaebca633a31e877", size = 2052931, upload-time = "2026-07-29T19:25:31.397Z" }, + { url = "https://files.pythonhosted.org/packages/39/e4/ad790b043a38b10cea13166c8dd716654ad8b227c20f12eebf6326191cd9/libcst-1.9.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:a5068bf6114f6f4d79af7a6c80a28d1deb50441150ea1db13b5458ab34bec159", size = 2043987, upload-time = "2026-08-11T05:54:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/15/6b/d3cd8275cc54ffc9817ade91442b3e6e9edd1a4518bd4e6dda13e3152dbe/libcst-1.9.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:7acfd18adcdd32dcf41ce676cf121002afcbe9ad2c72dc0c18d78465beedc228", size = 2204003, upload-time = "2026-08-11T05:54:04.031Z" }, + { url = "https://files.pythonhosted.org/packages/da/62/87eddccb11d5d221d50ac4fff4b6e9b8d48c547028d01f7d0fbc5c8a1d75/libcst-1.9.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:86361e2b426bd1b403e52375703c8b764e226e571adcb30442510710681edbf0", size = 2256053, upload-time = "2026-08-11T05:54:06.228Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/a44126aeb9fca8b4e342e0cc803ea00ca8f6cd5712619a7897b3eba13797/libcst-1.9.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8c14abe844bec8b021111b3985474e49f5af799c020e8e40dedcac87c97a40c5", size = 2270576, upload-time = "2026-08-11T05:54:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/bb/51/3af3dd44b117d66486e0ed61e43a16b7fc8cc5c372e1dd4ca0e70b888d46/libcst-1.9.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:8930971d2299b7006bb5d10c10038f44efb6da89d5bca2823595e31a9cdb96af", size = 2378573, upload-time = "2026-08-11T05:54:10.093Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e8/e545087d298dbf6494e84a8092f0f5dbd62eedacabd4ecd6b364367b4804/libcst-1.9.0-cp315-cp315-win_amd64.whl", hash = "sha256:5891ce9cff815077614f509e3a23888c5ddb8081d6188e8ba1172c0ccc369022", size = 2177937, upload-time = "2026-08-11T05:54:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/84/17/93a00a8e03494a84db102dd0e3d35b8876b1084ca2c194b331a168d86eb1/libcst-1.9.0-cp315-cp315-win_arm64.whl", hash = "sha256:1260b5d070a3324447a53a00387225a55472a62077ce01922d30a2a78cc4b138", size = 2058633, upload-time = "2026-08-11T05:54:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/d7/00/c3751b12eb81822ea0b6131d374a98bc6c024c4b9ec5f6ea8f53dc23e967/libcst-1.9.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:02ee2dbdb5c218116f16a021350fc267a14be205b50d81c33f5d73857ab6fbf7", size = 2036178, upload-time = "2026-08-11T05:54:15.545Z" }, + { url = "https://files.pythonhosted.org/packages/2f/12/48ff486eb5adc593f4818569e8896b74b672414ab4722a74b17e4c4cf31e/libcst-1.9.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:3e5b684191a0462b2d40a73261ea7f4da6a49e7a030b7e0fceca46bebb51dfe5", size = 2195496, upload-time = "2026-08-11T05:54:17.625Z" }, + { url = "https://files.pythonhosted.org/packages/51/18/b13a4669864d41a4801fed7b86ede1049dc9a1e1dde250a7ee1f9c3b1285/libcst-1.9.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:2b150c4f298fe54eb0fe73abf71f57db74d070796140a8c206c9615b6861f01e", size = 2245769, upload-time = "2026-08-11T05:54:19.343Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c7/cf2d46744825e84afbd7228fdeeea8d23cf6c4b2074253c27efb7705c653/libcst-1.9.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:50b913e37187f00a6fb88962009364e1cf1b1284aa1762304f34b52b972f2218", size = 2260576, upload-time = "2026-08-11T05:54:21.444Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/5433d3d62250b2e4325938db101766bcab2a006819684713bcccb6259a88/libcst-1.9.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:9f92c75283030fd58fb7d6e560168a00381e0e3368ec8a026a3ec8a828a05f93", size = 2368709, upload-time = "2026-08-11T05:54:23.276Z" }, + { url = "https://files.pythonhosted.org/packages/87/39/9f0e1690727623f99489895db0e42048a3e1e8cea0627517c593e437fd83/libcst-1.9.0-cp315-cp315t-win_amd64.whl", hash = "sha256:4adf97bb1aff8039b0bd4991e2f838be6e4fad552821b26b71a5d1a653c2582f", size = 2177866, upload-time = "2026-08-11T05:54:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/cd/79/9dc7811883e67ea771057e8937bc536eb3e77f3635fc5a93cd85b6fe1ea8/libcst-1.9.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8f0dd08a5773d7051e105250b2a2c73d8065ea9b2d68e7e1bdc5244660e75f93", size = 2052908, upload-time = "2026-08-11T05:54:26.835Z" }, +] + +[[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]] +name = "linkify-it-py" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/98/7a1a5f31fd5c7ba93e963b168e244b8e3dd705b3d2a718e3c3307583bf57/linkify_it_py-2.2.0.tar.gz", hash = "sha256:907acd2d17ac1fbb9ddb62c8957ccbd6158cac602231a15c3b0cd1e215f03cee", size = 32939, upload-time = "2026-08-29T07:07:08.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl", hash = "sha256:3adc40eb5af300b2605fcfdb968c24e1d780a90f1f2221af7c15e5111e94d443", size = 21971, upload-time = "2026-08-29T07:07:07.164Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/55/18dbb4778b30ada5ce071608503cc3edc9e14e13d868c17a6d178fc30f7a/markupsafe-3.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346", size = 11520, upload-time = "2026-10-02T23:04:36.299Z" }, + { url = "https://files.pythonhosted.org/packages/6c/14/0b05f79b4733e264a18d08fe08fa1df7347630ff32a6cb82180d9dccec55/markupsafe-3.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91", size = 12063, upload-time = "2026-10-02T23:04:37.291Z" }, + { url = "https://files.pythonhosted.org/packages/ca/3a/63ba10b6c1463216b3e4df669a9f0e5a3b0c3071557d2e8229e3968c79fb/markupsafe-3.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef", size = 24321, upload-time = "2026-10-02T23:04:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2e/5f015261b76ad633d187ef6f388b413aedd64a8773c4df59e530a0be5525/markupsafe-3.0.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169", size = 28845, upload-time = "2026-10-02T23:04:39.295Z" }, + { url = "https://files.pythonhosted.org/packages/33/cf/26e594b26be40c2f1fec63ccf8a8b99d0335a5b2cbe84835c7d82a994375/markupsafe-3.0.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb", size = 24701, upload-time = "2026-10-02T23:04:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/81/a5/a513b76c139a3915b43404324e55c0b7979ae4f0d39eb6f075b0282e90a8/markupsafe-3.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808", size = 22961, upload-time = "2026-10-02T23:04:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/46/cf/4c66192c100b4542bcbe392ae06696b670f66927be3ac38a213234778ff9/markupsafe-3.0.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692", size = 21920, upload-time = "2026-10-02T23:04:42.429Z" }, + { url = "https://files.pythonhosted.org/packages/cb/17/ac3662678bfbad649893117ada2ba44dc30bf56884e84f13154a792b10f1/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d", size = 23726, upload-time = "2026-10-02T23:04:43.531Z" }, + { url = "https://files.pythonhosted.org/packages/f7/af/fe47cee339180a69ebca3c57fb3483d0f5cbd1e8337d1871fb1d9c1aebee/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21", size = 22539, upload-time = "2026-10-02T23:04:44.618Z" }, + { url = "https://files.pythonhosted.org/packages/12/32/d55440ba140442800e02d799c9cb5ab597bf6ebdb1177b5ea39a11f797bd/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707", size = 23963, upload-time = "2026-10-02T23:04:45.656Z" }, + { url = "https://files.pythonhosted.org/packages/50/9d/9c86042cb364c2ad4c971e6d1247929effd25f714cd7ee11b05e6316445b/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e", size = 21506, upload-time = "2026-10-02T23:04:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/75/ef/5b824f03ba40c3b3652b6272d083d2fc4fcdd440de519a3a39ba2c3e7262/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7", size = 22955, upload-time = "2026-10-02T23:04:47.698Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e8/44cfcb5ea40e5e43cec7793ef90704ed0c475280839076c4345757eb8e59/markupsafe-3.0.4-cp311-cp311-win32.whl", hash = "sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5", size = 14094, upload-time = "2026-10-02T23:04:48.788Z" }, + { url = "https://files.pythonhosted.org/packages/91/89/f2b509f7bf79352e40117824c1070dbeafd4df67031d3fa98165a3134228/markupsafe-3.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3", size = 14328, upload-time = "2026-10-02T23:04:49.801Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5a/ccf22672a0f64dc682306e288f0dabcb06c7a201f3bb6e6cbf86d9e8ad03/markupsafe-3.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e", size = 14188, upload-time = "2026-10-02T23:04:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" }, + { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" }, + { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, + { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, + { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, + { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, + { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, + { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, + { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, + { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "msgpack" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/e7/bb605a7bab2d8425a64b3fa762b39dc1bf1c7e3f11ba6fb5413d6db0ff8c/msgpack-1.2.3.tar.gz", hash = "sha256:32edb81a2b5eb7cd7c9d941b2bfbbb082fd2cd09e0e725930316af6b708db186", size = 196517, upload-time = "2026-09-29T02:33:52.276Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/95/b9c651ccb9d720b2e2c8d537954dff528ab869a03bf89598145716db823c/msgpack-1.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ec90a9ae3e1169fa1171147340f0e97d941aa19fcd3b34e8339a55933ed042af", size = 90404, upload-time = "2026-09-29T02:31:44.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/cd/fc9e2e367e80f1493e2ec5f610dda558b344eeede296f88976db133e8f2c/msgpack-1.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9d7e9cbb0998bbfd363fd9a09c330520d5e9cb323c05b5a1a05865d23ccf2226", size = 89683, upload-time = "2026-09-29T02:31:46.413Z" }, + { url = "https://files.pythonhosted.org/packages/19/9e/1028485c6886c1c117f777cc9b053e541eff0fedb3292dfb1da95040edb5/msgpack-1.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6707d2fa2aa1bb5424ea0b05f44ffc989b15ab41a73ff5855bff4944fec7c8ac", size = 465347, upload-time = "2026-09-29T02:31:47.934Z" }, + { url = "https://files.pythonhosted.org/packages/aa/83/800570e6a22376eb8d599920f70aead4779a63611696f567477c4e85a70f/msgpack-1.2.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:382b219de3d436de3baba0f4b0c6d4336e8f5858d0eb047918b13b69a71c6c55", size = 477820, upload-time = "2026-09-29T02:31:49.479Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ff/817e4a2052f848d3fb67726908d6e4e7c19f68ee7c19553a82ce7b0ed415/msgpack-1.2.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:186e6c602b8a9968b8e864c67d622a69279f7d1e55ae25f40e3bff7e815b2b62", size = 436656, upload-time = "2026-09-29T02:31:51.18Z" }, + { url = "https://files.pythonhosted.org/packages/3d/42/040cc55dde6a7d92057baac8d1fc9cfb9f4fd4162900e2ec16dc33917a7d/msgpack-1.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9276ba88891338f2617044429dfd080ae008c9868a25f6f1a7d004a35dc9ac0a", size = 460939, upload-time = "2026-09-29T02:31:53.026Z" }, + { url = "https://files.pythonhosted.org/packages/09/93/4dc007bdef930eed247346773bc0189b710078961d3218d5ee7ba59f322c/msgpack-1.2.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c942c21a93f36b3a69e828c8945bb72c94dc2ffe488a2086950c812f3edf046c", size = 433608, upload-time = "2026-09-29T02:31:54.981Z" }, + { url = "https://files.pythonhosted.org/packages/c0/97/a1b944046f283ec89445cb2a982c42233b5b07cc630f9be739f4f1d469a3/msgpack-1.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18a6ed513023001b28dcd3ba54966f6bb90a38274ba8d2640464bcab3a1b81d4", size = 477373, upload-time = "2026-09-29T02:31:56.713Z" }, + { url = "https://files.pythonhosted.org/packages/59/79/ab411d0d172743732ab2503f4c32a22dd1a7d1436a6feecbb160e4b6376a/msgpack-1.2.3-cp311-cp311-win32.whl", hash = "sha256:d0238cd05dec9ffbe0de1071df685ba63e30a36ac155285b1a094e727c38cbe9", size = 67514, upload-time = "2026-09-29T02:31:58.267Z" }, + { url = "https://files.pythonhosted.org/packages/63/8d/6f0cb2b84e484e96278455c26870196d025bb0cec312b226a663f1fa9000/msgpack-1.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:30e1522e4173230dca4d9ad896f038f73c0da6c1edd42f4dbad88ac583cf5d46", size = 75850, upload-time = "2026-09-29T02:31:59.449Z" }, + { url = "https://files.pythonhosted.org/packages/aa/25/f99e13a2c1d3f5a1dcaa5aab27f474e8c4358188bbc68ad79fecb0d1aefe/msgpack-1.2.3-cp311-cp311-win_arm64.whl", hash = "sha256:8ca67f77938ea6a3663aa9bd22b3e031f6da84d665be850abab910ee90728dfd", size = 72338, upload-time = "2026-09-29T02:32:00.885Z" }, + { url = "https://files.pythonhosted.org/packages/af/12/4d7c6d6203416d9fbf0f59ebaa805e70fb929b93a41b611bc821ec5964a0/msgpack-1.2.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:89c930aece4e972b208ba589c8410b4167b05e411a5ea2cb25fd96f8bc47ee43", size = 91577, upload-time = "2026-09-29T02:32:02.141Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c7/8576ad39f4ca42ddad26f68eb8621d2d0a60501193d480f504bd9d7f36c4/msgpack-1.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:905a189853d6bdb204c7ae5f4ab77fb857448abfff574d3d93c62e2815b24b4f", size = 90027, upload-time = "2026-09-29T02:32:03.508Z" }, + { url = "https://files.pythonhosted.org/packages/0a/3a/aa9c580aea1314529a0f3562461479780b0d254b064f0880956bfbcc74a8/msgpack-1.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7b3d0018746b5997dd6b14a1870b07cc4c327d9101145d94a1fc264a51a06", size = 460343, upload-time = "2026-09-29T02:32:04.906Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cf/9c2e4d6c179529d5bf4a64cff76fa581486569e9fbdd35bd98f51cb624bf/msgpack-1.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede33b2892ceb976283e009ad12fa1834cfdf1f9c43ee9c97849fc588d00a618", size = 472998, upload-time = "2026-09-29T02:32:06.69Z" }, + { url = "https://files.pythonhosted.org/packages/7b/41/915c81fe6df2d3cbdb0dece4f1a5cd313e1cd2abd9f501d0f50c0582517e/msgpack-1.2.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:666ef5601ab0e6e345e47febc96aa81143cc932201543480cbb9499164f05ffb", size = 423216, upload-time = "2026-09-29T02:32:08.739Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e7/7dda8b1039abfd9bba4c5068172c67135c9e33089f503512db9226f23c24/msgpack-1.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87cf2ef05ff2f2493ba29fcdaef27e960ca64dacfd13460ae29e6f92e0ed05bb", size = 451218, upload-time = "2026-09-29T02:32:10.517Z" }, + { url = "https://files.pythonhosted.org/packages/16/5b/ce995c1ed4a0522b7f2d034bc2034fd63005f240b945961b70fb56fbaf3d/msgpack-1.2.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b774ff994d844e541439ac5d2d49a14def4104830c3465e9394c153f86200ffb", size = 422453, upload-time = "2026-09-29T02:32:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/ce191fb87e2650d0166b34c437e499ee4a7f9db9c1eb164f41725eb6160e/msgpack-1.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eaf7e82249837e3aa97297b34a0bb9ff562027381631e057cea6e1367f10b438", size = 469003, upload-time = "2026-09-29T02:32:13.663Z" }, + { url = "https://files.pythonhosted.org/packages/42/35/539123407fe200fb16609c835675496fbeb6017ace9fc93909f0613223ae/msgpack-1.2.3-cp312-cp312-win32.whl", hash = "sha256:7c047250096f9fc19dba26e3d1639b5e7a84114003605c94def667149a70ced1", size = 68303, upload-time = "2026-09-29T02:32:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/6f/4c/331b45f9b86fbda6b9e103244d189068e51f726d8c40021ed66e1f2c415e/msgpack-1.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:3ec409b0d6aa8e9eec6eaf881b893caa215dbe68c5319ca96e8a271d81bb111d", size = 76744, upload-time = "2026-09-29T02:32:16.344Z" }, + { url = "https://files.pythonhosted.org/packages/13/9f/fb572dc42b9fac06c7ea848aaee6e140d84469743bd1402bc07089fc4566/msgpack-1.2.3-cp312-cp312-win_arm64.whl", hash = "sha256:59612b4ed48a04cf024584218e813562f3b30a3bafa5f55abe300b15da314751", size = 71580, upload-time = "2026-09-29T02:32:17.617Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8b/3824d65e912e925d09ce30d9130fa9970d6d2855d7888b13639a6604967f/msgpack-1.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:21bfa4d2aa0b04c1806ef778a1199e9e53ea2441bcbf284420a32083896320b8", size = 91728, upload-time = "2026-09-29T02:32:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/05/e6/df7f2c9ebb94760113debbcea2bd3afe5fdab88a4f7bec1b618755517460/msgpack-1.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db84203b13aecc222f465061397fdd5b53b7ae73d2c95ffc1c8dc5be0153a709", size = 89955, upload-time = "2026-09-29T02:32:20.224Z" }, + { url = "https://files.pythonhosted.org/packages/08/6a/e5fc57136e8bacccb2b39627dea2cd546540a06181e22fe6db90e15b3ae4/msgpack-1.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e0d7950ca3c1bbae291d0552dd3bb2792fc680629c4c0d44e47e5bab969f3ca", size = 454930, upload-time = "2026-09-29T02:32:21.771Z" }, + { url = "https://files.pythonhosted.org/packages/b0/30/c394d37898db9212d1693456cdf363c7e1a097d0b63e10664007f3df3ec1/msgpack-1.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07c9733089d1b176c3dd2f7fa268452f9d5d784d076473499d754a58e8d1fbbb", size = 466866, upload-time = "2026-09-29T02:32:23.742Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c8/1e4ddf6f6b829b3ee6c530c79dfae89cb609d2b0eedb5e0ae716851c52d1/msgpack-1.2.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f24a43b3560e20f825b807fe1e874bd73d53abaf8bbdcf258a6eb152cddbc1f5", size = 418715, upload-time = "2026-09-29T02:32:25.262Z" }, + { url = "https://files.pythonhosted.org/packages/11/a5/f460ba6d7a12d4301002f3efbb8f841e8bdc9c5fc98d771689677a352885/msgpack-1.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6576f348ed6cc4f31db6fd915a8e94245f042f50eae08d48732425e70638ea37", size = 446489, upload-time = "2026-09-29T02:32:26.988Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/adface88db909bed321c85dd673655152d4a514c67e1f0800eb51c777d07/msgpack-1.2.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cd5a9f9f86a52c24713679aa2631956835f3842512964ff93f736ff76f1f530d", size = 416998, upload-time = "2026-09-29T02:32:28.606Z" }, + { url = "https://files.pythonhosted.org/packages/36/00/5bb3a239ccfc3763c4d0fa49b13b1b7010b00182c499ab3c1fecfe6294bc/msgpack-1.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9ddd28d3e9bbc602a9dced1591882c7fb9ab776eef8837da2c326fde19e2853", size = 463288, upload-time = "2026-09-29T02:32:30.375Z" }, + { url = "https://files.pythonhosted.org/packages/29/8c/456df77f00d701df9d6980ffb80291bce6e4e2e112e25a4dfae216f0715a/msgpack-1.2.3-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:62cc1a4ef0e553bac32c8342e1f04834aca7de276b92744eb7307db77759b890", size = 53347, upload-time = "2026-09-29T02:32:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/9d/22/ce780be666f89b77cdb855daa9ec62e87bb7f69e9f403e4a5d83a2b2208f/msgpack-1.2.3-cp313-cp313-win32.whl", hash = "sha256:d2f9c4f85e47a44d26d5baf3b041eef23436e224d44eed273f01bd8a12048d9f", size = 68258, upload-time = "2026-09-29T02:32:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/51/06/c3def9bc4db283103c5901b302ee2a4305cb1e69729244f94d9bd8f8e8e7/msgpack-1.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:bb89b5dc30469c84bbf8684826eb851d82412ca95690e111b9ac5e8fb343961a", size = 76569, upload-time = "2026-09-29T02:32:34.412Z" }, + { url = "https://files.pythonhosted.org/packages/12/9f/cef344073858b80adb92d6ea342e20b0eae7a8f6fe70281b69cf03707270/msgpack-1.2.3-cp313-cp313-win_arm64.whl", hash = "sha256:471e12a6a42498a31490c206e0069e343b6a7c35db540be73a879eb06f5be047", size = 71530, upload-time = "2026-09-29T02:32:35.892Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8e/f777f74e38731c428857933c8011596f2d2f3160c821152f23b6ffba862f/msgpack-1.2.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3a31905206722103a84c1f72633fe30692cff6732c9d262e09a27dbc468797c8", size = 92042, upload-time = "2026-09-29T02:32:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/a0/71/551608543ee5d590f7e8d522267665d6d9946866ad2a2a70a770f7c70793/msgpack-1.2.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3372475211a9ce1a23acefe512cb3e121d18c95dc74ed56cb1819ef40836ebf4", size = 90578, upload-time = "2026-09-29T02:32:38.883Z" }, + { url = "https://files.pythonhosted.org/packages/ea/11/6d78ce5a9a58bf9ba7b1b6a8f649173b030e6770c8019cf330b91825ee5d/msgpack-1.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9324c54995641c3d1f92a9d55093c8cde0ffa2fbc87a467a688ef60428393220", size = 454352, upload-time = "2026-09-29T02:32:40.34Z" }, + { url = "https://files.pythonhosted.org/packages/3d/08/feb9a196269ba7809f44f9117d9e4a601c41c313f6144fd0c337293a5488/msgpack-1.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8ef3a66e4b52d2d7fdd90df2984670124b2ff7546d76bb25dcf68ef47f7df58", size = 462562, upload-time = "2026-09-29T02:32:42.176Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/3a674f366def24140b103d1ffd4fd27b3d912a13e47da67422afa16bebb3/msgpack-1.2.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:902f3490db0e07a7d40b48536a85c9b28fbf1397e7e1658a45a55f958e303620", size = 418134, upload-time = "2026-09-29T02:32:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/48/82/944e71f280577490d99a3951cbce21aa4cbe04e7ab42cb373fd668af883c/msgpack-1.2.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e51eca14fbb65c4e0a5a9657346962bd3dca78c08e04e3d4dee70ef48687d30", size = 445937, upload-time = "2026-09-29T02:32:45.739Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ec/feddd629c4a3edf1395313680450c525086cceab56dec0d4de9da9ccb618/msgpack-1.2.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f42f146752eedb6765f07dcc04d72dab0a25779ec8d4a88c0085263ce114f22c", size = 416450, upload-time = "2026-09-29T02:32:47.558Z" }, + { url = "https://files.pythonhosted.org/packages/e4/59/263a10f8c4613ba0713f48cbda7695ac8dd6d6fab2fcbc9168f03f23a94d/msgpack-1.2.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0ed5823c4efc20fe87d3530665f40ec18a002be003114814c21235cc8d256207", size = 459546, upload-time = "2026-09-29T02:32:49.145Z" }, + { url = "https://files.pythonhosted.org/packages/1e/21/addcfa1e583cfc8a22fbdc57526621b5decd7ad676ae12e9150b7be1be5d/msgpack-1.2.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:2487453ca1b6104442c6442f9a1a8fee1fe8f428a70d99d4cba799108b304150", size = 53462, upload-time = "2026-09-29T02:32:50.708Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2c/3cb5c8524a1335ee27ca952c7ab78d375a16fea8e18ae3767ba0c880416c/msgpack-1.2.3-cp314-cp314-win32.whl", hash = "sha256:6df430419f2338cb71e4a34d6e64f83c88ccd321f91f40ba4513400b36d864ec", size = 70294, upload-time = "2026-09-29T02:32:52.037Z" }, + { url = "https://files.pythonhosted.org/packages/23/f9/9172ff3cdb85d160ad06df5e2708a5fce7682982a5eee8d31869b9f69d2e/msgpack-1.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:84a6616d396ec1bc18a1e83e67c96a393ec35dfe5e17434a5be7b9aa0fe988ab", size = 77778, upload-time = "2026-09-29T02:32:53.429Z" }, + { url = "https://files.pythonhosted.org/packages/04/e8/b4c23178bcf605ae17cec48a75530dd69d49b0a5a6f5f4df5c47d59f746e/msgpack-1.2.3-cp314-cp314-win_arm64.whl", hash = "sha256:7a003b02c6ee2eea6dfe0bb08818631e3597e69f0131f2a8250488a1cc553290", size = 73794, upload-time = "2026-09-29T02:32:54.763Z" }, + { url = "https://files.pythonhosted.org/packages/66/b1/92704be352c4f428b7e0a0e0fb210cb1aa2b1c42c102b8dc22d34b82fac0/msgpack-1.2.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ccea05b5542f6d283fef3f0a8e93a7f0be90af0ddeeef84c25c0216ba76dcae1", size = 93721, upload-time = "2026-09-29T02:32:56.342Z" }, + { url = "https://files.pythonhosted.org/packages/49/78/9c91f1e86cadcbc100b3780fd429c3715648704032a612e77a00646ebe79/msgpack-1.2.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b1631e12fe572e181cd77e831f69335d6cd5278eac22e3db3f33cf264ac2ac18", size = 94256, upload-time = "2026-09-29T02:32:58.056Z" }, + { url = "https://files.pythonhosted.org/packages/91/4d/270f9725921ae88a29d37a774a77ac24f0ef1411fc960a63f5a4665e81b4/msgpack-1.2.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e54394b7dbe2e12ab032d9d21feef7bb61a90a150a2623633ba3781ba69dcb1f", size = 471673, upload-time = "2026-09-29T02:32:59.886Z" }, + { url = "https://files.pythonhosted.org/packages/48/b8/eaa8d930f72dc1d1dd79511dc2ccf965922b059f2f0ed3b30aebac8c4b11/msgpack-1.2.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63bb7448a1e9111319ae2430c09a5596140c160422830d6271bc75730ff2ff9a", size = 466257, upload-time = "2026-09-29T02:33:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/97adc805037bc7e24c4e2f711bbcd3b28be8ec9aea3e778f18208cfbdb46/msgpack-1.2.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:382bc88fe90f29f5ac8a0b65c7046ff255356f2f2f3186c30e370215736fa1dc", size = 418484, upload-time = "2026-09-29T02:33:03.402Z" }, + { url = "https://files.pythonhosted.org/packages/0d/7e/1c53302606fe436ab48ba539ebafafe4a6a9efe12c4f04dc7eb36912d93e/msgpack-1.2.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c77e27790ad72989db783d5303825fba0b71550f00a490efba35cde7dc4b719f", size = 454064, upload-time = "2026-09-29T02:33:04.977Z" }, + { url = "https://files.pythonhosted.org/packages/00/2d/9ee0170f638907b396c15c6cd26b3e54f869159efc6206683acfd8f696e1/msgpack-1.2.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:700bc0fc9e968a292b9137ee70e7a012f7e115bf0107ce45e3a88202788dfc1e", size = 417901, upload-time = "2026-09-29T02:33:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d2/905c84490a75cd15a27065407cd085d201f7d392e1e0411f49f03fd31ade/msgpack-1.2.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5bd5f91ea75c45cafcc5433ba8fae59b708b736ec178d2441c40c499e9e079db", size = 459896, upload-time = "2026-09-29T02:33:08.361Z" }, + { url = "https://files.pythonhosted.org/packages/37/cd/4ce5809b9ab3b114d7cca64863e436820fa1614b49d55ccb93d49824ac2d/msgpack-1.2.3-cp314-cp314t-win32.whl", hash = "sha256:7995a7c6a62a1d6e7df211b4a16de513bd99fd053525050a319f80f44fb8015e", size = 75983, upload-time = "2026-09-29T02:33:10.023Z" }, + { url = "https://files.pythonhosted.org/packages/8a/31/853bb580744c24be0dbd8b090c3e6987dce466a1fc840fe50c0ac2ef9044/msgpack-1.2.3-cp314-cp314t-win_amd64.whl", hash = "sha256:bfe7d5b62cbe7aa664f0b3e2c49077f10fcdd06183d3014f8271ff3c5edbfbf9", size = 83757, upload-time = "2026-09-29T02:33:11.441Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/9f1b2ee484414eef9e21ee2b2b23b482bb71433ab9bac1da03cbda15ebf5/msgpack-1.2.3-cp314-cp314t-win_arm64.whl", hash = "sha256:1f585407f740a9eac04a3bb82c61d68a0ea78f90e29e670bfb086b9ce3a518dd", size = 78128, upload-time = "2026-09-29T02:33:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/47/b8/50db4235407c3802f622b4ccdf65c6fe1e48d3c3eab6981fa6a9a5e53f11/msgpack-1.2.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:13221a6c81ebb8e43ea63a7251c35d54e4175cea37ebf3a62e911bdf42562a3c", size = 92111, upload-time = "2026-09-29T02:33:14.476Z" }, + { url = "https://files.pythonhosted.org/packages/15/56/50cf2a45c6163edafd737e2fd555103a26ce6748e1e241fb56ed445ea835/msgpack-1.2.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:0955b9000725573d1457c1676944b370dd9643c8d18f25bda5ac72913f850949", size = 90583, upload-time = "2026-09-29T02:33:15.924Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/8cc02f767c3bc94d2649c954d28dea935ce9398eb9c93ce2444bb9474cc1/msgpack-1.2.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c91762c48cd686dc9cf2b142c0bc544083952de32f5853d6624c956e54b85e5", size = 454751, upload-time = "2026-09-29T02:33:17.475Z" }, + { url = "https://files.pythonhosted.org/packages/80/c9/ddb896767808e3e022453d8dfae26fd52ed404b0aa6fb7f752d39c040208/msgpack-1.2.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f4ae8bd4ad9ba085fde95e95d055a896d19210238a4199a771a3cf36dceed49", size = 463597, upload-time = "2026-09-29T02:33:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a5/e7c261abf75783c07dcac89951cb31dd0c123bf02fbdeda0c67303e698d8/msgpack-1.2.3-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7013534a7163aa4f213c4d9864f1a8a7555daac6fcd48f699a198e29b436bfab", size = 422661, upload-time = "2026-09-29T02:33:21.093Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8e/466d5133f9e1c2e232e15e304f715b62f6f0e28332d18e37d975fe174315/msgpack-1.2.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:6a834097144aabe948b8ca9020a833e8026f7d0abbd0ec54bc7e50f45a8ce012", size = 445188, upload-time = "2026-09-29T02:33:22.877Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b4/33e7ad987ee2f4b3d449a6cbf28f574ed222987ca7f65ad277072646ac5e/msgpack-1.2.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:d31864ba3933a589b6a00249f89c0eb422197f49128fc10da550e57e9cb0f377", size = 420451, upload-time = "2026-09-29T02:33:24.485Z" }, + { url = "https://files.pythonhosted.org/packages/34/2c/9d8be0d6c16e7e6131cd7da20257dd3da65473e3e6df0c00572fb10a195c/msgpack-1.2.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e15f70588f4db8cd10df0930145b186de70feb9db51710cd378b1399009655bd", size = 460624, upload-time = "2026-09-29T02:33:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e7/3a04783582c6f44f398cbfcf5f07a111192126ec4e63edf7f5640143bf64/msgpack-1.2.3-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:b949cc25e4a09252cbcc54e66e507de914d0e94a3a7039bd54c299bf7037c098", size = 53474, upload-time = "2026-09-29T02:33:27.83Z" }, + { url = "https://files.pythonhosted.org/packages/68/fb/db07359851644e258609d84f8e4fe0030ef448c108e20afe73f2a3bf539c/msgpack-1.2.3-cp315-cp315-win32.whl", hash = "sha256:8ec7a1d49ca6c2569d722ab5ec86e90089b0713900aa31905b47b4c4d9e78ce0", size = 70344, upload-time = "2026-09-29T02:33:29.382Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e4/cf5584d2f2a2e4465d5896a855a3e75a34a20ab172360b3d42ad862dd1ce/msgpack-1.2.3-cp315-cp315-win_amd64.whl", hash = "sha256:79dfa38faf92f804aa61beec140d70b18418e1dde1778dbb77a87a4cce85aa8a", size = 77800, upload-time = "2026-09-29T02:33:30.941Z" }, + { url = "https://files.pythonhosted.org/packages/63/f9/518ad4e8a580027b507eafdd26de7aae661a714e43d7c111c212482e4a1b/msgpack-1.2.3-cp315-cp315-win_arm64.whl", hash = "sha256:ed899d73a22f286a72bd9528d63f2ab3030dbad8bf1527fc249319a50d61fb9d", size = 73871, upload-time = "2026-09-29T02:33:32.406Z" }, + { url = "https://files.pythonhosted.org/packages/a4/79/254d4c9ad642b2a3ba84e646787892b34cc815eb36c9976f67a1c4f38515/msgpack-1.2.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f56fba61b2516be7917cb00151f0d060b5b21184e3499bb57f0f7d9259bea124", size = 93370, upload-time = "2026-09-29T02:33:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/5a2ba167646a25e84eaa8894e12935351e4331b80c28a9237ce6fe8d375f/msgpack-1.2.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:69ad12cedb674c73527bed869cddb42b742cac79a207a614202a4abaa24ea173", size = 93959, upload-time = "2026-09-29T02:33:35.503Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a1/2b44612e55f7cf5d5e4b580294959b4429bbbcb1991177888e3e18668137/msgpack-1.2.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db9fb67a3a2e75247bae569d34ebb5ff61c0448a4f0d6dbf991dae68af39b007", size = 467921, upload-time = "2026-09-29T02:33:37.023Z" }, + { url = "https://files.pythonhosted.org/packages/0b/6e/3309798ed1c11d7fcfdc7b946642685b0ff1588477925bc0d26bee7dcaae/msgpack-1.2.3-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2574ef81c1c8c38b10e330f3f9406fd09198a776b002030fafcf8e7647e9e06e", size = 467310, upload-time = "2026-09-29T02:33:38.799Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/9c799f489fa4146de4e00cfe9fee17afe33d8012f88ddffffea94f7c4700/msgpack-1.2.3-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fafc3b8898b432b841d30a61082c599fa7f4d06885f9dc58ad72259e12059fa6", size = 420178, upload-time = "2026-09-29T02:33:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/5850dc9cafcd2ea315692e65db0e222d20923dd55f44adf35061003de27e/msgpack-1.2.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a393e428f6ffb0dcb73308c1fff5593041c16ff42da66e5bac8a83a6107a54b0", size = 450248, upload-time = "2026-09-29T02:33:42.366Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d2/b4c806e3497fe21f0b353568266aec14ff735d092aea672de7b2955db03f/msgpack-1.2.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:d1c1e8989a855b7f1f2a64ec4a80b23a631822903952770813857b2e4f460471", size = 418431, upload-time = "2026-09-29T02:33:44.178Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f5/f4ecc3ddac4d551bf2f3cdb283ec546dcc826fe7c500074be61aa273e08a/msgpack-1.2.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e0bd394e999949c814f7912284243298de1b5a17b6a3dcb6cc8a79b156ffc4fa", size = 457543, upload-time = "2026-09-29T02:33:45.978Z" }, + { url = "https://files.pythonhosted.org/packages/a4/69/1c821d8386fae5cecc5fcaacf3de3947ff0a23f16bb481b5532b5868372a/msgpack-1.2.3-cp315-cp315t-win32.whl", hash = "sha256:3d4c807ed050fe3ddbea5ba7e9f63d7136871ce42861be1f50ff739f0e91047a", size = 75820, upload-time = "2026-09-29T02:33:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/68/9e/41e2f7343a3764a9c1fb10c79f9a6a05db9df93dedd76401d1b511f5a685/msgpack-1.2.3-cp315-cp315t-win_amd64.whl", hash = "sha256:5f304123b90e8b2e49867981b7f6061612c39f50cca51ee88de007c084cf68d3", size = 83345, upload-time = "2026-09-29T02:33:49.325Z" }, + { url = "https://files.pythonhosted.org/packages/80/cd/0c3aa439bc7a7bf24684fef3a0ad776cba170e18ed94445e723bce42fce7/msgpack-1.2.3-cp315-cp315t-win_arm64.whl", hash = "sha256:f41ca154b7737b11893cdce3c78c61d703398a1cd54d4297bdad908392338a8e", size = 77572, upload-time = "2026-09-29T02:33:50.729Z" }, +] + +[[package]] +name = "mutmut" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "coverage" }, + { name = "libcst" }, + { name = "pytest" }, + { name = "setproctitle" }, + { name = "textual" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/30/b334a22184026d92a7549c8a76ad1d397ea65a6f8534ef486556ab861c70/mutmut-3.8.0.tar.gz", hash = "sha256:bc670276518f893ab356f1d28826c49b7e606e7cf89c300e3888c0b52fc1d2ed", size = 83631, upload-time = "2026-09-12T16:53:58.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/2a/a9904a45804c1de50c0c3baad1512b78d5b66d2befd841104b8a62bf0f35/mutmut-3.8.0-py3-none-any.whl", hash = "sha256:4c0abcef6b8d1116f2adfaad19df12e1c621e4a402d4367ef15bf8cba497b91d", size = 81469, upload-time = "2026-09-12T16:53:56.906Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/105de02c1322cfada6d9710d9146ef8026419d433c9d08359a2d35805811/nodeenv-1.11.0.tar.gz", hash = "sha256:3ce8fe5b71d16e8af7039ca65257354100bc772965d6bc549070649e53b1b146", size = 99301, upload-time = "2026-09-26T11:29:21.367Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" }, +] + +[[package]] +name = "nodejs-wheel-binaries" +version = "24.19.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/76/7e97195e14346598565a0de4ca8bdbd5e634b3fb5b1ba590b7b1b89f8a63/nodejs_wheel_binaries-24.19.0.tar.gz", hash = "sha256:db217eef8cab8551667863379b08db4d9067403f6cbbe87481eb40edceb8aa9b", size = 8058, upload-time = "2026-08-19T21:47:19.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/52/0774b52c7be8151ad9d5aff44edc100c3f13d6d8eb3765f63ffa40e69fe8/nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:e12cbfd69089504e42fb14194ce734a9dcf3eb38c820ca63dd511d36fb964e9c", size = 56047203, upload-time = "2026-08-19T21:46:43.448Z" }, + { url = "https://files.pythonhosted.org/packages/67/3a/4fdbbfecf2c23d52c0e3f68de7f7c1b3c97a26d328c69c5f6c49c48e340e/nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:1c890adf4b7e6556ccc1ca66c866bb81884b6c9a581dee4e530dc7f78fb9d514", size = 56219459, upload-time = "2026-08-19T21:46:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a8/0147149415195c59b8a72a594916bfb80d6be4d586f9fbfda313889e0efc/nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4e029dadfae1295876063c96b236f673487e8c27379fe146c1e2250283520227", size = 60588256, upload-time = "2026-08-19T21:46:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/89/6631d0982353da1bb7bc00bb1988f702822c62b42634f57999ee53b5c337/nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:4196a947bcc883f2003ab101762d729f3e99b5e86b75bd09151563403e2eceb8", size = 61123607, upload-time = "2026-08-19T21:46:58.117Z" }, + { url = "https://files.pythonhosted.org/packages/32/a2/fa30f0841e4602995782e124359f9b910c7b481d98decf61ef0b2fc3ebfb/nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:352e048ab4dd35e7de5f338d1cc4fcbf77a0e93da30bf7336a8217ee246b31d7", size = 62632842, upload-time = "2026-08-19T21:47:03.42Z" }, + { url = "https://files.pythonhosted.org/packages/18/01/22d97ca72213f66cc386ee638db30c2e62757fdf761c6029074bced83d1c/nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:28d078b2ced9e2069516e652dc4b1380e7a1a7f2d3934eccd1611586d283ba4c", size = 63250653, upload-time = "2026-08-19T21:47:07.938Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/e3be8fa327a795bcaf7a19cd84299e338a7bce32ff0665fdce9cfa22573c/nodejs_wheel_binaries-24.19.0-py2.py3-none-win_amd64.whl", hash = "sha256:67e3abeb9c3830cae8c8487ae8a2af7cc27dfa75af06145cee5ca7d1857c81bd", size = 42448503, upload-time = "2026-08-19T21:47:12.093Z" }, + { url = "https://files.pythonhosted.org/packages/1d/37/34cf28ba1691a060174948a9927fe61091982d6048b2e403071a9acce443/nodejs_wheel_binaries-24.19.0-py2.py3-none-win_arm64.whl", hash = "sha256:d9074c665ea68b04e183d82482c86dc907d3a9bd15eb6cf85542cb785266bb36", size = 40090155, upload-time = "2026-08-19T21:47:16.032Z" }, +] + +[[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" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pip" +version = "26.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/15/4500e320e6b101ec3b719ae85b697d9940b6cda672bc555bd6016fc60c6f/pip-26.2.1.tar.gz", hash = "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f", size = 1848877, upload-time = "2026-08-04T22:51:14.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl", hash = "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e", size = 1816632, upload-time = "2026-08-04T22:51:12.472Z" }, +] + +[[package]] +name = "pip-api" +version = "0.0.35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/08/47dfc28aa16ef0dbc38dd7c6e4fe24d124c3ed3a722a4a3e5021584a588c/pip_api-0.0.35.tar.gz", hash = "sha256:14dea05d7b3b68f4c7582213e3ae8932bbba8e1f754bf06b95263d5fa6ab4363", size = 123884, upload-time = "2026-09-08T19:35:12.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/2e/d424c5b5877be862dd9a2c8b7e9ddb13382bb25e132e6dd50cc09c0a7dde/pip_api-0.0.35-py3-none-any.whl", hash = "sha256:66990d0da25378555cbb538db79384bab92d4617c6350dfd7a2126ba2a3a9894", size = 123943, upload-time = "2026-09-08T19:35:11.537Z" }, +] + +[[package]] +name = "pip-audit" +version = "2.10.1" +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/66/a4/f21d5f0a0edabcbce31560b73c7c5a6f72ae87af4236fd1069c8f59a353d/pip_audit-2.10.1.tar.gz", hash = "sha256:1eb4565d19ebe5d48996f4b770b4d2b32887e12cb12cfa637f1a064011b55ffc", size = 54275, upload-time = "2026-06-10T22:17:01.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/a7/b0c504148114047bd1bc9d97447453c6850ca176bb2f3c0038835994e8b7/pip_audit-2.10.1-py3-none-any.whl", hash = "sha256:99ef3f600a317c1945f1e89e227ef26e1c2d618429b8bd3fa6f4f7c440c4611a", size = 62023, upload-time = "2026-06-10T22:17:00.309Z" }, +] + +[[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.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/91/4a03cfdb03314cfca262921797fff04ff4054bd86a160aed76eddf8aa12b/platformdirs-4.13.3.tar.gz", hash = "sha256:5e567f664eb087ab8521c0179cd8d1bd60857d271136567a39719e28e2d383ce", size = 61559, upload-time = "2026-10-16T01:16:16.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/b8/fd1af06b079af236f5423f7c1821264419cc8f6b4803f79353acbb8bfa53/platformdirs-4.13.3-py3-none-any.whl", hash = "sha256:f6ad7f447f24f8a3b82cce5976387428bff894a0eca6c3488f4a17f153c130c4", size = 32762, upload-time = "2026-10-16T01:16:15.051Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "plumbum" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/d2/578712a979a50e9aba08ea173a38e7598461130d3c9899f176373bc03280/plumbum-2.0.2.tar.gz", hash = "sha256:233751d7819c9e6743ec1c2405927eb4fa52a284c7b894bd10e28106a9309a92", size = 390799, upload-time = "2026-07-22T04:13:12.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl", hash = "sha256:a865771826c1d2cce0ca8b2a2c48aa0447ed7a518859e389a95f158e9c369547", size = 168804, upload-time = "2026-07-22T04:13:10.76Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/c3/6965c2690ddce885ec75121572d065a5b94c5a5d8d3e657927ecda9fd715/pre_commit-4.7.0.tar.gz", hash = "sha256:1f3deee12b914c8fa184bd2b5953391c2e4f36763e403d4a23dc9983fa7c6e86", size = 198120, upload-time = "2026-10-12T20:45:40.32Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/83/ce0a196e4f7b20b11ce3aa1fc72fb973f64b7628d51c1b8aebad37fb7f81/pre_commit-4.7.0-py2.py3-none-any.whl", hash = "sha256:2e229038ad3656081b70c27913157e4b636ef306f643f024a8a8e3f0f7b2f877", size = 226261, upload-time = "2026-10-12T20:45:38.858Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.53" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, +] + +[[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 = "pydantic" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/1b/ca81811da3d2aea724fb3c4ba6b83f50ab8f210576306ccd601973142d35/pydantic_core-2.50.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c531166c42ea7bdfecc8c50049581f05dd1993b09cc7c52bb36a14e96deaec7d", size = 2152145, upload-time = "2026-10-11T18:31:58.046Z" }, + { url = "https://files.pythonhosted.org/packages/59/20/938ef7e0541af0a314e2f9d63848dbd1656ba2ebfe5ca0469f7ebee00d5e/pydantic_core-2.50.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6d0c2183008c188e19f4906d426b293bdc4f67ab17df8e180fe16cda208fa71", size = 2045761, upload-time = "2026-10-11T18:31:59.35Z" }, + { url = "https://files.pythonhosted.org/packages/2f/4b/456497fe2affa18f49b226e89bde31e8a204751ee343dd9beaac5438123e/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94be440c03fede26969a5ce75468e0e6a9927a1b46d9b679ee8adc1b057b0350", size = 2049557, upload-time = "2026-10-11T18:32:01.121Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/cd5dfe13928f55e236dff3a1bee06721d318ca6c955d9ca993b0a5d09f39/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:36c426eac0af8d1529ff8467e612b933346caec1fdc0d774f78f67a1a11e16c1", size = 2188153, upload-time = "2026-10-11T18:32:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/d0b25bc484f8d9136ea76ece69e0b2d24ab370cc2e7bbd1b79e61723b763/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:028e2f212273d4a39b1ec1e0de8166b1165a65fc0f1111452a9d94fc7c625c63", size = 2388308, upload-time = "2026-10-11T18:32:03.767Z" }, + { url = "https://files.pythonhosted.org/packages/c5/64/6513cac7c9f78492abafa01d57a69de1d246bf3085fd7b0330bf14b025b3/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6f0cc1bb9900dc558960894adeb30b0c083366fc1d69b856209fb2ca5c36fe5", size = 2431187, upload-time = "2026-10-11T18:32:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/ed/60/7f2ae89c37e69a3dc0da9ff57b0388c285542c688d05f36767770429de85/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8812592c85d0edf423f10eadcef42716d71e8219085ad9e85b775057b7306133", size = 2157623, upload-time = "2026-10-11T18:32:06.616Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/e3a71373b85a7973099b9a229a4302c7792262717e0b4997e41c01f7dd16/pydantic_core-2.50.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:bbce99252ba3167b2b6277f1829d5bf4b43b754524bddf7f944707c3db7d2253", size = 2254539, upload-time = "2026-10-11T18:32:07.968Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d5/5b620aaf40dc631d911565b64d49d3fa8422027c046a7a96dc4e4921ae98/pydantic_core-2.50.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:476f6ed8e43cd1e0b460920e23571700872b284e77331cb30c4faf459cf48a4b", size = 2267344, upload-time = "2026-10-11T18:32:09.399Z" }, + { url = "https://files.pythonhosted.org/packages/e0/48/f46baf4580d8a56ca4782aa65842e16d3cf39736b29fcce8e9c0521b3422/pydantic_core-2.50.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2cbd1b75b09e976ed0d6b6ca297675632ca35df86130088457cdc60ef36970ae", size = 2300877, upload-time = "2026-10-11T18:32:10.903Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9d/e7d28505e6092c1fe075838a4f793ccbe6dd676e44ab16175815ee06cfe0/pydantic_core-2.50.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:5958c72adb417c39b12ac87525ac60b0d73315fcdc59e21f44ee4a5e2512c9ef", size = 2464270, upload-time = "2026-10-11T18:32:12.329Z" }, + { url = "https://files.pythonhosted.org/packages/22/b1/3bf63b82efd93ebc9968f9c12fc46037a28bcb452c88e4688f33e5ff7dca/pydantic_core-2.50.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d8f9e8a6c4ab04b78d61f78627370d834eb004b2869dcb28cfffa647b4ea1980", size = 2478653, upload-time = "2026-10-11T18:32:13.735Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/26d1e4b9c2ba6a92b7dc75bbcb99ef44456b7e3af1bced868403667ea7d3/pydantic_core-2.50.1-cp311-cp311-win32.whl", hash = "sha256:4be846f55c9477f5f3ddde8f2ce941137e16862a56d018ed885d422bb6ae02f2", size = 2120246, upload-time = "2026-10-11T18:32:15.591Z" }, + { url = "https://files.pythonhosted.org/packages/24/65/3e875e7991eaecd4d70686293ff0d8a99aa8f93dc295e6a21c3d702912fb/pydantic_core-2.50.1-cp311-cp311-win_amd64.whl", hash = "sha256:0048b6dddc8ef4b64fccaad878bd143b0c3882ea9936279dc11d613f6b7dd1bc", size = 2176332, upload-time = "2026-10-11T18:32:17.153Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5c/58dbd17c61f209d91c70f549eb7a3bbec7023e89ea12dead091462d6d658/pydantic_core-2.50.1-cp311-cp311-win_arm64.whl", hash = "sha256:6a733778df2f7087ec1100ed0b41533e4f3001976e99570fa34f57c66e7f8e3e", size = 2182471, upload-time = "2026-10-11T18:32:18.49Z" }, + { url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" }, + { url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" }, + { url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" }, + { url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" }, + { url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" }, + { url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" }, + { url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" }, + { url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" }, + { url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" }, + { url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" }, + { url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" }, + { url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" }, + { url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" }, + { url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" }, + { url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" }, + { url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" }, + { url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" }, + { url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" }, + { url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" }, + { url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" }, + { url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" }, + { url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" }, + { url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" }, + { url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" }, + { url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" }, + { url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" }, + { url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" }, + { url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" }, + { url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" }, + { url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" }, + { url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" }, + { url = "https://files.pythonhosted.org/packages/e7/73/a23a327237d9bb985298ce47702e196f995ce850224c12c70886bd66bb5c/pydantic_core-2.50.1-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:062e891facce5ca296a1c37098e5e466780457f86413894b399f0cf22934f769", size = 2162560, upload-time = "2026-10-11T18:35:06.274Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/33b37b5e82408f402a5e82649a43e7c95cd5dab7beaeef2c35cedbfd18f2/pydantic_core-2.50.1-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:49c2cbb2397fe4d0987e84606e691af6cb87bc0ee1bd3e7b737f7e10b4c142f9", size = 2075250, upload-time = "2026-10-11T18:35:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/04/62/c7d5b9249466ccab90f3ce4790c2399dbfb8172ae9e74a7753dd295fe65a/pydantic_core-2.50.1-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e4472072de0137ee0d8e72d6620e85939c271d2f90f6bbb4b15c24638b79f92", size = 2107891, upload-time = "2026-10-11T18:35:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7e/28d29bc1657195933147f772dee0558d9e0f045bbc11db7f992f40c37bb5/pydantic_core-2.50.1-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ed4f3cef55164b026fefb41341b7754cc6b624c75dfe7142d2ecceb5ad21c87", size = 2268026, upload-time = "2026-10-11T18:35:13.336Z" }, + { url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" }, + { url = "https://files.pythonhosted.org/packages/bd/35/002a378302ff91d2a7f149bc8d22363faf581ba40ac39d0123444315a32d/pydantic_core-2.50.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7456d699b13954e9c0164dcb267250a10ae0dfb03e6e26d6796ab0d46e189c84", size = 2154076, upload-time = "2026-10-11T18:35:24.743Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5c/7a034330082c00f3c9795052bd9b161aaaf6e1951e897ac00a094823ef7e/pydantic_core-2.50.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0d955195bbbe489ad343fcc956eacea9357b79cb22192c66cacdefcbc14b32f", size = 2085850, upload-time = "2026-10-11T18:35:27.009Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/d5b44a7d3d4eceed6ef8713a0003af56fefde6ec75915513bcdd5d730cd9/pydantic_core-2.50.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2c634642694e6a0dad2ab1d375589fa671fd442edd5caf7d9737b8f6ca22906", size = 2259754, upload-time = "2026-10-11T18:35:29.763Z" }, + { url = "https://files.pythonhosted.org/packages/b6/88/e01dd37301710bd8b3b2daf34ec9e2a6e83a333130831c0d468b0292a48c/pydantic_core-2.50.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6db2fbed51a7991302e8fac498cd67e336246026d0dfa84cf5166ce1412760", size = 2268712, upload-time = "2026-10-11T18:35:32.654Z" }, + { url = "https://files.pythonhosted.org/packages/3e/43/5bb80d4a4d2b2d84612a30205662cc521a20d209c94abc079ea48b4e0df2/pydantic_core-2.50.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:79490e33c4c0fcb933bbbcfc3a62184d8803b99f535863dfbb925e1bcb6945ad", size = 2305049, upload-time = "2026-10-11T18:35:35.392Z" }, + { url = "https://files.pythonhosted.org/packages/e8/7b/e3ef76b269cfe4a5bd3bdcf5d7124cf05e5a8909d443b91639307116bdae/pydantic_core-2.50.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:48569b0ade9edfbe065cad1d700175546592aebbb42f02adcebcc26e75b896fe", size = 2466001, upload-time = "2026-10-11T18:35:37.791Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7a/ea0947aadc1d9f72d4be00d2e19939611ec17363bcb5e6eb9ec7d8221c34/pydantic_core-2.50.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5f3cae32fc46121f787cb2486de9cf95a8bf72aec5cc78f64c606fa1735a6ef5", size = 2481777, upload-time = "2026-10-11T18:35:40.287Z" }, + { url = "https://files.pythonhosted.org/packages/24/f3/d15bc0b1fb0c4f1e07b3326ffb7489f7ae70da2b56a0884d22641b0eb47c/pydantic_core-2.50.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7f476456ac2bb0d937f75191494a09c83a30765fea4f70f3b404942fe25f6cdf", size = 2336787, upload-time = "2026-10-11T18:35:42.558Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/54/fad125f5c48f7821644ee6f223ebfcedc4e23597c1a16eeb13d33aa4a91a/python_discovery-1.6.3.tar.gz", hash = "sha256:a62b301d96cf5489cb96ab023b32e068421867fdd5df2e42eece877869047fa2", size = 88998, upload-time = "2026-10-16T03:54:29.507Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/ec/643826e4d69b7bdce4213f221e8f5ed06d0a9dd69fa9b33342578d2cc8c8/python_discovery-1.6.3-py3-none-any.whl", hash = "sha256:cb7654125e3dcb594269a6feb56ac693f1f13b7c654493a93eba72dc22d18034", size = 39386, upload-time = "2026-10-16T03:54:28.237Z" }, +] + +[[package]] +name = "pyyaml" +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/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" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { 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-ft" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/eb/5a0d575de784f9a1f94e2b1288c6886f13f34185e13117ed530f32b6f8a8/pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab", size = 141057, upload-time = "2025-06-10T15:32:15.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/ba/a067369fe61a2e57fb38732562927d5bae088c73cb9bb5438736a9555b29/pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6", size = 187027, upload-time = "2025-06-10T15:31:48.722Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c5/a3d2020ce5ccfc6aede0d45bcb870298652ac0cf199f67714d250e0cdf39/pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69", size = 176146, upload-time = "2025-06-10T15:31:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bb/23a9739291086ca0d3189eac7cd92b4d00e9fdc77d722ab610c35f9a82ba/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0", size = 746792, upload-time = "2025-06-10T15:31:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c2/e8825f4ff725b7e560d62a3609e31d735318068e1079539ebfde397ea03e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42", size = 786772, upload-time = "2025-06-10T15:31:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/35/be/58a4dcae8854f2fdca9b28d9495298fd5571a50d8430b1c3033ec95d2d0e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b", size = 778723, upload-time = "2025-06-10T15:31:56.093Z" }, + { url = "https://files.pythonhosted.org/packages/86/ed/fed0da92b5d5d7340a082e3802d84c6dc9d5fa142954404c41a544c1cb92/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254", size = 758478, upload-time = "2025-06-10T15:31:58.314Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/ac02afe286275980ecb2dcdc0156617389b7e0c0a3fcdedf155c67be2b80/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8", size = 799159, upload-time = "2025-06-10T15:31:59.675Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ac/c492a9da2e39abdff4c3094ec54acac9747743f36428281fb186a03fab76/pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96", size = 158779, upload-time = "2025-06-10T15:32:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9b/41998df3298960d7c67653669f37710fa2d568a5fc933ea24a6df60acaf6/pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb", size = 191331, upload-time = "2025-06-10T15:32:02.602Z" }, + { url = "https://files.pythonhosted.org/packages/0f/16/2710c252ee04cbd74d9562ebba709e5a284faeb8ada88fcda548c9191b47/pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1", size = 182879, upload-time = "2025-06-10T15:32:04.466Z" }, + { url = "https://files.pythonhosted.org/packages/9a/40/ae8163519d937fa7bfa457b6f78439cc6831a7c2b170e4f612f7eda71815/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49", size = 811277, upload-time = "2025-06-10T15:32:06.214Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/28d82dbff7f87b96f0eeac79b7d972a96b4980c1e445eb6a857ba91eda00/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b", size = 831650, upload-time = "2025-06-10T15:32:08.076Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/161c4566facac7d75a9e182295c223060373d4116dead9cc53a265de60b9/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a", size = 815755, upload-time = "2025-06-10T15:32:09.435Z" }, + { url = "https://files.pythonhosted.org/packages/05/10/f42c48fa5153204f42eaa945e8d1fd7c10d6296841dcb2447bf7da1be5c4/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e", size = 810403, upload-time = "2025-06-10T15:32:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/e369064aa51009eb9245399fd8ad2c562bd0bcd392a00be44b2a824ded7c/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255", size = 835581, upload-time = "2025-06-10T15:32:12.897Z" }, + { 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 = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e7/e6d5749983b020e6cb1055abec0a6f361a8e94f0184cdcf56d4760995f02/ruff-0.17.1.tar.gz", hash = "sha256:5bb796c5112e9fb9527f2ef2faa0a130d6312d19de92bcfaddd70dbb2d41020f", size = 5323349, upload-time = "2026-10-15T15:23:18.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/78/90c46ca6250709f7c37e397b60d06061adee8d0f7d13959ed6a8e49b14fd/ruff-0.17.1-py3-none-linux_armv6l.whl", hash = "sha256:1796c41eafec66f4635125dd1e074a7aa27b8d20c187d484360acf11d247fc4a", size = 10147528, upload-time = "2026-10-15T15:22:33.667Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d2/a2e28bc35b083ae51fa07dfba5280a667c8b01ecaee7802fc8849dcd5c46/ruff-0.17.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8eadb09a788010be29c895418bdfa513e4e1c65e64b94e5b19e4cd381f6cc7fb", size = 10225974, upload-time = "2026-10-15T15:22:36.328Z" }, + { url = "https://files.pythonhosted.org/packages/8e/71/7a9029a81c8206ee01a98f6cfeeb56e6c095af86857581a37045c46d7e94/ruff-0.17.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:29b454f18ca3eeaf37fbf140841dcf27d36109e29dee9aae8acecb68c26c6f74", size = 9270361, upload-time = "2026-10-15T15:22:38.651Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8c/e075e9c917cb0575297880c96768304e881240171c870cbd8d9b7b54bec2/ruff-0.17.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872e03dd231744d6c5ec5c8af6be52b71323fc6ee096e4c8b34d9f844ee3b978", size = 9938300, upload-time = "2026-10-15T15:22:41.284Z" }, + { url = "https://files.pythonhosted.org/packages/87/27/0b4bb926a49de2383f161272b4d863571a4c7ecdbf595a6bc58813a13367/ruff-0.17.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c0d6cb5f8fbac77eec9af49f4fc0eaf856b05545e372b0694d24b4e76b1c640", size = 9948464, upload-time = "2026-10-15T15:22:43.681Z" }, + { url = "https://files.pythonhosted.org/packages/14/27/518d57e0e02aecb8ea4acac6b57b51a53173d25fa734952bfdd7d531e88e/ruff-0.17.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78beced4a3c94dfedbee4b999b95b6a27b35820fdc0afbacc6d37a8da26c1cd1", size = 10638639, upload-time = "2026-10-15T15:22:46.081Z" }, + { url = "https://files.pythonhosted.org/packages/01/82/d89a4e498ade59e6cee52d67f8da569019372212b87468c1fdeae884601c/ruff-0.17.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7feefc63921202894f0e158b0e32f45a5950d70a7fb693ccb9264816c25b1c05", size = 11337238, upload-time = "2026-10-15T15:22:48.894Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7c/3b0fdef9352656317c977086fe1d802902406e4f2a0d8578da90c0e6d1c5/ruff-0.17.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684154861a819bf1f433669d2a0ce9c3e54677c65b8ebb866cc2a7ec86345785", size = 10896327, upload-time = "2026-10-15T15:22:51.45Z" }, + { url = "https://files.pythonhosted.org/packages/62/86/5566290219708ce2928f3a29c4f843a1185b341ec6111c4631a33c5ceced/ruff-0.17.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6ef98e51de8ef38082f90653ceeb84944bd7673783eea13e1647c5b9b064189", size = 10350811, upload-time = "2026-10-15T15:22:54.401Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d2/ca8462dabb3c413990d49703ba306c30c862195a75448d080fa8fcce9775/ruff-0.17.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8f9aa5b5bd5fa1937803ea609c8f153b8de39bf3e0e149897ae34b0c5b52f467", size = 10569994, upload-time = "2026-10-15T15:22:57.142Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ad/dd1864c1f1db17c8cfb297ce70b352388e6977620f66f7182c52855407df/ruff-0.17.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:80e53b0e5faa3c5667f035daaa032893ae42b7cfe708c886d39ed131e54688ab", size = 10059672, upload-time = "2026-10-15T15:23:00.084Z" }, + { url = "https://files.pythonhosted.org/packages/65/45/3f56cf1d2168c2165cdd5ac6f9668aee66a4cfdd4caf2b2cc5ccb2b1d568/ruff-0.17.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:056c5d5fa0ac790051ae165d7035c7bf956d02de2db557f2e9b31d877a505fca", size = 9947803, upload-time = "2026-10-15T15:23:03.132Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4b/5457cb2be4c1d1ea5e919558131a0f197a4a454a0530d9e30820e8206e4c/ruff-0.17.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd8f5a55b10f371642eacd0e8665a42a8b97da15c9a7187c79e697b77b5e0ce4", size = 10351807, upload-time = "2026-10-15T15:23:05.628Z" }, + { url = "https://files.pythonhosted.org/packages/49/fb/db15b4f8fa24a1e36b99e1401f42ef6df16ee299a48670d5d262b771e7d7/ruff-0.17.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ce05e6f3973611d9550a600e04bb531061e368baba31610eb57c1509a8f21a0a", size = 10765716, upload-time = "2026-10-15T15:23:08.092Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e8/1e186578076aff7a80735f625005fde678412ab609cd323ed182eb7d6b87/ruff-0.17.1-py3-none-win32.whl", hash = "sha256:1de0030a8aa78c65c5fecb84b2e5c390ac684c0452875a02afc7c083d49a496a", size = 9895093, upload-time = "2026-10-15T15:23:10.582Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2e/da3d9589bae8e8b58f3a48bd5bc0b980232fb14a83bf02c3be7660f87c1b/ruff-0.17.1-py3-none-win_amd64.whl", hash = "sha256:5df76f16580fab58d0f6c98a3ac254a4abceb7b2489b8b678335869cff77b3d7", size = 10097955, upload-time = "2026-10-15T15:23:13.309Z" }, + { url = "https://files.pythonhosted.org/packages/df/c9/0f2a4dea06a5e12c1191ee3d96b1e1233797d0d5e18f97a2b909a95a9836/ruff-0.17.1-py3-none-win_arm64.whl", hash = "sha256:42400b9a6ff8515ff27796f07f92e0195be011c86e81e381606f58ea5502fef4", size = 9853542, upload-time = "2026-10-15T15:23:16.05Z" }, +] + +[[package]] +name = "setproctitle" +version = "1.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/b0/6b8a516c5a9e9630bd5293db78314ac012f690305fe93beadea388626efb/setproctitle-1.3.8.tar.gz", hash = "sha256:cafe209d064a6efb88cb45a03e97981ff8832802b2b5d009dde0197a3b7b41c8", size = 27214, upload-time = "2026-10-01T21:09:27.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/78/d065a73bef541ab5f6f094c705c016563a063a4ba9e839030a845f2087d7/setproctitle-1.3.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cc711e639a3e978978ab3bbb01f8f106715f5c23bbd99ced5af2404528bbaa6c", size = 18131, upload-time = "2026-10-01T20:58:40.67Z" }, + { url = "https://files.pythonhosted.org/packages/6a/95/2981f400b09b0675225f45b105f1c99e3dfa603b12e6767fd09fd5e58209/setproctitle-1.3.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:50f9bce172b6f9ead1c8fe813d85b1dde57ab503a445f982026ac3a16c719e1e", size = 13140, upload-time = "2026-10-01T20:58:42.383Z" }, + { url = "https://files.pythonhosted.org/packages/2e/89/7a19bba7f4a354fc3499bf83614b27452232235b1d44191f46ff396051de/setproctitle-1.3.8-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1a6f6a3b2920f4362e8d924f9e5b084f0b2692a2c140e348abd2e3a07a1def73", size = 32316, upload-time = "2026-10-01T20:58:44.544Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/cc23c7184c59e3511b024aac78181d71a4d68f957cf7a07b5cff13404db0/setproctitle-1.3.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:861380b2244983d12b6fe00e4a49f16f9946932245a252dd16c1f5ef7d1be632", size = 33226, upload-time = "2026-10-01T20:58:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/42e67ea6e5e36cedd0dc3df0846e0b4879ccf68fb36eb27f80b31c292ded/setproctitle-1.3.8-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e03a3bdd844e20a611518f7532e951c63d25bb27ccae7d7eb02100542540c14f", size = 35035, upload-time = "2026-10-01T20:58:49.092Z" }, + { url = "https://files.pythonhosted.org/packages/56/48/a156953dfab59df378ac92acca0e6873edf653d94e1a6a08d4a2bcf8add7/setproctitle-1.3.8-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ecb5101325e0254694e55d46ff936926296626bf81c902b60328ead152170bfd", size = 31481, upload-time = "2026-10-01T20:58:51.19Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6f/bed22b21f6a4543bab99d10d5374850470ec3080616a29fa36e07ccf42b2/setproctitle-1.3.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7bd9f7a369a2914102c375d98998c97d669306372732c850dd8319d5a92f515", size = 32468, upload-time = "2026-10-01T20:58:53.413Z" }, + { url = "https://files.pythonhosted.org/packages/17/7c/0a64d381b652dcbe07538434e8340d1a07dbdc9b6646f87e398fc0e39f3d/setproctitle-1.3.8-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c3cb92b3239e8094c2454a2160e45417490d4fce69cd09ac846a0c3e43360fb4", size = 33772, upload-time = "2026-10-01T20:58:55.849Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/4c79334f634538e0197cc2f1817ec35a10f8993fbf93566353b0d7e750a3/setproctitle-1.3.8-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:eadfb2ec0948a791846f7cc411c18ff5bd8359e8102cf78d39b8a28bd98b7c6a", size = 30770, upload-time = "2026-10-01T20:58:57.851Z" }, + { url = "https://files.pythonhosted.org/packages/46/2a/c7626d22877c2a413e9b8114c68f00f42a228efc517652a6d37ca29f4768/setproctitle-1.3.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23a193a32a9cca10c4062e2efa3377e804d0209903c734940cc6626d6dfd018a", size = 31894, upload-time = "2026-10-01T20:59:00.076Z" }, + { url = "https://files.pythonhosted.org/packages/47/4a/e78ef703b7f7e18a1ccd0fcdda1ce39f11680da96dff22a285fe378a35d9/setproctitle-1.3.8-cp311-cp311-win32.whl", hash = "sha256:3a6337e7c0f50a702106e6652764117c442a0468292afb8a7406ff7c483bfc8a", size = 12104, upload-time = "2026-10-01T20:59:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/1c/2a/ef5aac7428f98385cbb065add9d2257d969600f1c2db489859e5d868689c/setproctitle-1.3.8-cp311-cp311-win_amd64.whl", hash = "sha256:66aad6ca59e4197d8e49de384474d8b9ecc6e10989523ca7339cecf7ef2a34bf", size = 12943, upload-time = "2026-10-01T20:59:03.323Z" }, + { url = "https://files.pythonhosted.org/packages/b1/36/e7a4b93d7457ed12a1a2b49b042c25848a8847b15f7808159f2ccce91fdd/setproctitle-1.3.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f9a49d8103df362272c625641a4f7b104ae584db21d0f1eec3b7a5d0d7750ed8", size = 18121, upload-time = "2026-10-01T20:59:05.288Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/6a10d95bba71fd5f441dda01ae374ebcaeaa637a09f72b95e8cc59443958/setproctitle-1.3.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:823d7d8790a4f8484c7fa65468fcd9d009f13ffe5f0bc89ec0e0d2c165ac0335", size = 13126, upload-time = "2026-10-01T20:59:06.865Z" }, + { url = "https://files.pythonhosted.org/packages/cb/21/4df9fbc0c72b9bca39b5d53eff6813dc1738a1562983e0c614054e9dbb8d/setproctitle-1.3.8-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4a765e0c80a2453480be27c4fc22ba1c0771867040c5f91e4221940d78a4abfb", size = 32929, upload-time = "2026-10-01T20:59:09.199Z" }, + { url = "https://files.pythonhosted.org/packages/5f/07/1476dafdbf5d124a05ae4856272908c0796fa65844376a32bb066f04afda/setproctitle-1.3.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f61345393548bc72844d837d62fcf63444b0f5dac418544beb6d4f628a1c5df3", size = 33765, upload-time = "2026-10-01T20:59:15.018Z" }, + { url = "https://files.pythonhosted.org/packages/81/c6/db7b3b859ba0fad54d6a352c3f99a29691f11be133649f61a30a3798f2b0/setproctitle-1.3.8-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba59516fcab4b621b80966a32f1195770696c113a948d00845f35b762e335db7", size = 35614, upload-time = "2026-10-01T20:59:17.439Z" }, + { url = "https://files.pythonhosted.org/packages/c2/76/1acfd4b72d5e1cb56ce44b028325d12fedd3410bfac02e15d3de03fa049a/setproctitle-1.3.8-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f523d34ad72b811be61e5027a046ad4670b54fc3c6384056f06b59f77d671af0", size = 32215, upload-time = "2026-10-01T20:59:19.993Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a4/ce856b9aea7bcf1be539b1811e9bd937ca3cd1edf99516a66b71132cfcc3/setproctitle-1.3.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c667cc9c4cd949814b4ba8be70632a6454637647f3feea2724e02d3d3e35c66d", size = 33169, upload-time = "2026-10-01T20:59:22.201Z" }, + { url = "https://files.pythonhosted.org/packages/88/40/d96d641620e0cf4d8ec09571e5be293aa45f66cde574fc9c069b0ecbeb9f/setproctitle-1.3.8-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0d2f756ede847e05e7759af4f2d87dedf3ed34004092216be85acd8301b1d385", size = 34429, upload-time = "2026-10-01T20:59:24.228Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3c/ef0eef79863fad43ba6aca4fd96ab3b16358e8e37fd6d794bc8f7b88bf6a/setproctitle-1.3.8-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:191888bdf7c223c8b00eaa935947de0904165dee5e84d2055aecbb473d9c3f51", size = 31401, upload-time = "2026-10-01T20:59:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/bb/97/7c667a2eae40a693b26186b34384ce9035f4563b7053e2e254f7738da75a/setproctitle-1.3.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9c7b9b6ba3d0f6e504be5978854966168fb95b17ce126eab9a6132fe15f3e540", size = 32724, upload-time = "2026-10-01T20:59:28.481Z" }, + { url = "https://files.pythonhosted.org/packages/77/6f/33460cd16f81cbb367b55ce5e55bcee14ee05e16c8dddac83fddce308cc6/setproctitle-1.3.8-cp312-cp312-win32.whl", hash = "sha256:349499c0b21a940c3cb93d938013d40313711911e2fbaecd4700e2b7c236ca57", size = 12106, upload-time = "2026-10-01T20:59:29.924Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/243fa4a41c565eea22c9e7e5c14cceb3a6d1e10e2586da94692a30cef2a5/setproctitle-1.3.8-cp312-cp312-win_amd64.whl", hash = "sha256:f599401675bdcac21176ca385ea3139fc04c1f8e26724dc59f4fc9df1d1528ef", size = 12939, upload-time = "2026-10-01T20:59:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/58/46/125791eb19c85340c6d43de8c821b143aecbfa0882343f244f5744474229/setproctitle-1.3.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a4a782f9ab26e662a890c1a7d865c452c83ed554de44aa83da35fa5bdcc79921", size = 18100, upload-time = "2026-10-01T20:59:33.41Z" }, + { url = "https://files.pythonhosted.org/packages/47/51/73b1e62c3e019e683e58d31f9f8af57e6750f36ce079004d29b7790e22ce/setproctitle-1.3.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d63adfa4be8f9dbac4be6b359e2a8eb15a733545ef613d02a12ae27a4d40e975", size = 13116, upload-time = "2026-10-01T20:59:35.159Z" }, + { url = "https://files.pythonhosted.org/packages/78/ae/095c6ad159e3a7b95bc6d9a3eae18abfea1ebc79ed3377617406a1668150/setproctitle-1.3.8-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d4289e209e805d7d276a534f062092974fbe6814889d4c132c247205ebb49a65", size = 33275, upload-time = "2026-10-01T20:59:37.235Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/5196258efd941cb874b3f4da91460601b867fdc434eba48a70455736d4be/setproctitle-1.3.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bb543b4b58ad839f7f0d335a7f4b40cef6989952e7239de3532de0b7771607e", size = 34124, upload-time = "2026-10-01T20:59:39.529Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/e9754e0916e6dda232a8c337bda69d2fea33acee0c5d3bdf580b8155a50d/setproctitle-1.3.8-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b705f01a531dba8fbbab90e30d432af4cc70a79c0a33b238e2c061816140cad0", size = 36006, upload-time = "2026-10-01T20:59:41.501Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a9/c63d2d745f18423937429a8611743e7b3e17259173150fbcdaa59daa4a51/setproctitle-1.3.8-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e286e4c4e90063072a6e088a8d6526a7ffceb47429db88ba50ba58eeebcc758", size = 32268, upload-time = "2026-10-01T20:59:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/36/0c/a72818902f32ea9b848db5ccc828eb9cd9bc8661975610e1d46ea4aacbe2/setproctitle-1.3.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7b77c8671974288cf192e481ed63e928f1086762e97e8845d09d65ba121f8aaa", size = 33179, upload-time = "2026-10-01T20:59:45.809Z" }, + { url = "https://files.pythonhosted.org/packages/bb/55/fa59463ae6b957048e0e842d4b5513eced1b71554d8af0f5d91cd23cde23/setproctitle-1.3.8-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0e85b55eac9abfa3166ab00dac21e718ac2624a1df7d0b75559a2d3e3781cb33", size = 34472, upload-time = "2026-10-01T20:59:47.858Z" }, + { url = "https://files.pythonhosted.org/packages/b7/77/1f0cf55055788bf7eb9a146040a64d7ec42cd56153894856effc70e943ec/setproctitle-1.3.8-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f215ec9237d5f8e96d0205be6e2141292c20b133343c2153b6d4934f523ef422", size = 31428, upload-time = "2026-10-01T20:59:49.753Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/d1d8a6cd620d72a04f5d3335125e5b2fc5667bd06a76bb5bbc50b101e804/setproctitle-1.3.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b0e70a5d294e4c8cf7dc94e015fca6d4913779a424937e66b429fba82d8eb5e2", size = 32776, upload-time = "2026-10-01T20:59:51.763Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/8b9eea1e80ac7a7f622eead8a0e4d73a60098344ed6f373e111f7ce8d29d/setproctitle-1.3.8-cp313-cp313-win32.whl", hash = "sha256:f85b4d87273e0ec63568433bbb8ccf160b548c49b53576fe010bad9f698b8586", size = 12105, upload-time = "2026-10-01T20:59:53.444Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/98883c658859d331faf91eccac8946cb2f8feb2590905015b129fdeb09cd/setproctitle-1.3.8-cp313-cp313-win_amd64.whl", hash = "sha256:bcdf74fe8a6e3b31bed1bc7d3e2a38cfbda3263d2750492ea0725dbc2db5aacf", size = 12933, upload-time = "2026-10-01T20:59:55.045Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1b/15043f8886aecfb31759c4a7c2cda9f85971242a10723049fc0fea34629f/setproctitle-1.3.8-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f5f1d7e0d57cd75febd70c6fc26c2ea06024ab2006011abb3094b9fea54801ee", size = 18104, upload-time = "2026-10-01T20:59:57.014Z" }, + { url = "https://files.pythonhosted.org/packages/0d/be/c2a6880394bef30aafe274d00ca0c7dac34fff1fb867f6c51abf876a640f/setproctitle-1.3.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b8954fc72fdd49588c8b8d615eabc27dfd8eb6d0cffe987fc5df02934816fb19", size = 13124, upload-time = "2026-10-01T20:59:58.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/96/6b3818e7f63a30f13bbe97b1ea0a51509a20b5b2d48c95e526c3054343d5/setproctitle-1.3.8-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4d6a0245679b74e0fda974edb672cd1f2351b0d762279b6776ca3a065c9322a4", size = 33180, upload-time = "2026-10-01T21:04:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/4f/43/fd3220b4bb2169d8723b3561ae7f8e1ee6f2e13e2da38b3a7a150004d2aa/setproctitle-1.3.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:394aca04e9f11c469f4f29d25a40a79eb4b8c9a72f438ba6390b776590f89757", size = 34071, upload-time = "2026-10-01T21:07:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/58/ee9eeebeb74125723b60eada6e6b2def747815228193327ce470da088f2b/setproctitle-1.3.8-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f92f6ded50c0ae7b0fed612be77c5345ed4a4578f99b98c7f8258d0e599eff16", size = 35923, upload-time = "2026-10-01T21:07:19.054Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fe/b339602e96d73b29fd123c8b9c886e8f9a5afa12a18218eece56bd99c668/setproctitle-1.3.8-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c09f15930910dd895ac113b16c0b3ac2051dcbd44b0f017f94cb51349ba11583", size = 32116, upload-time = "2026-10-01T21:07:25.121Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5e/8d46d34648d8dfdc3f3e941c149ae316b6c6f417391018e279a1bb733c42/setproctitle-1.3.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4756a96435917494ec2f27f0da46bc05eecaa1a7e072d57220ecb2d46ea8a3d3", size = 32980, upload-time = "2026-10-01T21:07:27.826Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6a/10720d27d5f4a47c9af51652b49dd22313be20b9d542e72f80b4b363702b/setproctitle-1.3.8-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:65279b2df2951b64878da5382363e730d242486179c440c8653fe39aa3cc9e62", size = 34307, upload-time = "2026-10-01T21:07:30.62Z" }, + { url = "https://files.pythonhosted.org/packages/6e/65/95dd7b0245fdb7d2f6633613c559ecd9f0b2afeb6e3d962c218b6a1722d4/setproctitle-1.3.8-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3d2ced815b940c88097e07182c57cbfe1a0156839d1edc9be2bc0e4c74ab4eed", size = 31126, upload-time = "2026-10-01T21:07:33.59Z" }, + { url = "https://files.pythonhosted.org/packages/fd/96/4d62c4ba70cf8fa305f1dcbe35e93449819bb27b451fcb473556acf92650/setproctitle-1.3.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:37a91697c3b96bac58b6936b3bf0366af96a2a6cef963fdd9aaf0593a4bd1ff2", size = 32468, upload-time = "2026-10-01T21:07:36.312Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4a/1ec517435d4b572f6c468795331c7b376ec310eca5b1321504c6647b85a9/setproctitle-1.3.8-cp314-cp314-win32.whl", hash = "sha256:7cdd3d6e5e8abcee233157615d88dbb80527a5a4eccb95a93fbf92e6d2c8f9dd", size = 12255, upload-time = "2026-10-01T21:08:12.203Z" }, + { url = "https://files.pythonhosted.org/packages/7c/13/e74a4fa864a0a694104ff74fcb3bef523be94321bac1a8ece64ad04ef233/setproctitle-1.3.8-cp314-cp314-win_amd64.whl", hash = "sha256:5cff3bdbe6be556cc19bd072ff1ed9b5a8f4cbf356cd19b1ca94506b3e2b29f6", size = 13159, upload-time = "2026-10-01T21:08:14.231Z" }, + { url = "https://files.pythonhosted.org/packages/38/d8/019116e7defcd5d2c9264da4a82d369de6806a9161adf73e5f73e63fb715/setproctitle-1.3.8-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:84320e61f3906cc550b900ab1ba02ec53edd2d2e1e917f0873cba16592801c89", size = 18105, upload-time = "2026-10-01T21:07:38.655Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/bfa9d6b5574903c17e02caba0e25815bb1c7e7b895d594475676baaa35b4/setproctitle-1.3.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8086259943fc75eb07e11c1d08600709b9ce4fb2ec9bbd2490556c47d3ead135", size = 13125, upload-time = "2026-10-01T21:07:40.774Z" }, + { url = "https://files.pythonhosted.org/packages/d3/29/c7febeedee66b929c335d7b8ce83b1c4baccc65c94a4112fd8c1ea32b57d/setproctitle-1.3.8-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8879d303d4e3559efdba033ff198c6869010f7958412ec2047674881859fdd7f", size = 34818, upload-time = "2026-10-01T21:07:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/36/b9/90fea18bcb74f19d411df7e9e2d981c4d3e99817870acb0b5cac6540629f/setproctitle-1.3.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7de4da27fa4481e8a7a4ef58c908ffd2b996847c3d7fbf27b5c53ce0984c408", size = 35622, upload-time = "2026-10-01T21:07:51.03Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e9/5899eab6e0b4a127e98a268a157b6bdba4f8ad18d0ed90fdf3e081c20a3b/setproctitle-1.3.8-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:741b1ae7e1cd68745b8b677daba73685dab2d1b8bb12500e6537f3482a91150f", size = 37762, upload-time = "2026-10-01T21:07:53.758Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cd/190748a1f68afe6154b62517d2288b7d4c2092b0efa6e61d044c0f99d17b/setproctitle-1.3.8-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c85ff5b000eef8a280333ca158483a76f86bd374816add5c016a9f4294471289", size = 33548, upload-time = "2026-10-01T21:07:56.148Z" }, + { url = "https://files.pythonhosted.org/packages/26/db/889062b0970f04bc76d8eed03165dc17de8df282399ad931cb10a23b33a3/setproctitle-1.3.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:656ffe282ec0ca18308eebb100898c79b4f5698a93228d50d2f2a4a09474d7e0", size = 34298, upload-time = "2026-10-01T21:07:58.898Z" }, + { url = "https://files.pythonhosted.org/packages/6d/bc/adedb2c18eb7bb152b6bc59187f46fc328e7bb729bf28a7e4e43521c4c7a/setproctitle-1.3.8-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ae87e7b3e6414b3dd887adb72d2c4ea9f210c5f48efafd295ade4a58c760d29d", size = 35897, upload-time = "2026-10-01T21:08:01.561Z" }, + { url = "https://files.pythonhosted.org/packages/38/8e/f1769dcd1eb668b01ad251eb6780b030752d18dabc1b82789b255189772e/setproctitle-1.3.8-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:09cb645c6ae440286391e256203fc03522b3b0f55fda8219f698abb5c447cce7", size = 32435, upload-time = "2026-10-01T21:08:04.112Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/68be1eed21bba3ce27c6907ddcf0559cb99e4de3aa3ee000a38994371b73/setproctitle-1.3.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:84003eeaf2d7460390bbff1dbd7bee5b1213e7ae47a489f0fb7aad6acd2b045c", size = 34024, upload-time = "2026-10-01T21:08:06.514Z" }, + { url = "https://files.pythonhosted.org/packages/40/7e/f275783d6915fac997dbfa3729330552c4f2ddcd4f1bba7ff522f5f05c28/setproctitle-1.3.8-cp314-cp314t-win32.whl", hash = "sha256:ac4e037d276ce27069cad0083c4611b45232278bd2fbac33f06ef9438c171a1e", size = 12259, upload-time = "2026-10-01T21:08:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/86/59/3347f13aa38c81d78f5c93bc55e4355c43550f47348e5de48c5080f3a1b2/setproctitle-1.3.8-cp314-cp314t-win_amd64.whl", hash = "sha256:c284642b229ba7b7b927fb044aba03694b880ab572db3f07a10adc786db1ff7b", size = 13163, upload-time = "2026-10-01T21:08:10.087Z" }, + { url = "https://files.pythonhosted.org/packages/92/e4/e9866831fd0cff677c67488059fab3786a843ff0bd86f4b3c44bea8dd763/setproctitle-1.3.8-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:63348d86ae60b071a898e4241b41da1346d8c36f1a60bf3b698ae5eb35b79865", size = 18107, upload-time = "2026-10-01T21:08:16.029Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/5c11a8c8cf3b91e54fdf4c93d1c79d99a491e04d77d34c80a90552fe6b46/setproctitle-1.3.8-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:59239b6e5007b1bec79ef444a390b3bcd59cbcb2686c9910da5a42f1e99f738c", size = 13132, upload-time = "2026-10-01T21:08:17.756Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6d/47dea0a33ec67a2813e40bd92deea74c3732fd598bd71b636a92897eaf17/setproctitle-1.3.8-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:38310d9cf94959e39d22fdc0154c66c8bdbadfa790c524b04cb36da6333c7507", size = 33509, upload-time = "2026-10-01T21:08:20.254Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7c/5d6945012077e6c393d59432df42d281d213f508bcf6d83a1287ab2eb9ae/setproctitle-1.3.8-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a279b136ed97407209d2dc9c30d1e65405d1a4a06ab162cedd9c4159b51f8f20", size = 34405, upload-time = "2026-10-01T21:08:22.796Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d9/543a4470bfd01529dc35f0014dc68c70665db8b790e3647b41d155203b87/setproctitle-1.3.8-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26997a85aa70bb088e86f0b7899fb0c909be824d22fc025e87b0005afbe59261", size = 36167, upload-time = "2026-10-01T21:08:25.35Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/fc55041b162833c4bbbbfe78ed417bcab7b22f9fa72aff1722ff6af46fae/setproctitle-1.3.8-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35fc5e10fcba6df25d676ef908b44a36c17e7f167738ed016561185e24f4d274", size = 32623, upload-time = "2026-10-01T21:08:27.778Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e2/8c73c990c338b9ca5bf4fc6167ac864a80a53a10e71fd2755ba146f1fce6/setproctitle-1.3.8-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f5df0503093ec8c3c7e1da53920e79c62ab653b51b136c4736caa252552a8732", size = 33510, upload-time = "2026-10-01T21:08:30.431Z" }, + { url = "https://files.pythonhosted.org/packages/cd/61/8e10270d3a7326dc4e8a1408c631e6ae912625b91f38adaf04794a19e07b/setproctitle-1.3.8-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0a582308ba43afa7233d28243d9d1212e11c81d3c187a43929ec0cb70c4781c5", size = 34528, upload-time = "2026-10-01T21:08:37.161Z" }, + { url = "https://files.pythonhosted.org/packages/77/d4/00773ca8c876123348e65edfd9eec47464bbfa1a31067c6e1cd382356a0f/setproctitle-1.3.8-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ce48dbf74e8ec618d74cbaffe1159a4c32a4428de598f0ccdb0782bae618a72a", size = 31681, upload-time = "2026-10-01T21:08:39.874Z" }, + { url = "https://files.pythonhosted.org/packages/d5/42/19c6b634cca492575f9d99868bc35134528fb390d683d6a0396f61df0b8d/setproctitle-1.3.8-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:dd3a8fcee7d8a23e44781144830dee97b53ca4e0380bdd2a0e05b13913ce5725", size = 32999, upload-time = "2026-10-01T21:08:42.36Z" }, + { url = "https://files.pythonhosted.org/packages/94/34/09f653f687e4a1a6f515642519cc65f0e6bcf34631386380924fc9785b44/setproctitle-1.3.8-cp315-cp315-win32.whl", hash = "sha256:50beee618377ce6fd35767a9327f2da594b36778eb10b503ee2dc9b7c801f433", size = 12251, upload-time = "2026-10-01T21:09:17.504Z" }, + { url = "https://files.pythonhosted.org/packages/89/69/ab9afe8c198594533ad5eed6dcea647e1743ac8196a6c6dea974724b72e6/setproctitle-1.3.8-cp315-cp315-win_amd64.whl", hash = "sha256:f5916010de8b6045d7c63d98bb3f7a1e13f8c9c1046e859f2fd8de79c4e2d865", size = 13161, upload-time = "2026-10-01T21:09:19.295Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/da93e1ae012f2515473a45fb33f5ba2ddbbfcce1381c5e41005b31ab3230/setproctitle-1.3.8-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:e9f643bd97374aeb7e4274e5c361277bf1428e8e55246cee6823fa3525dc2c04", size = 18119, upload-time = "2026-10-01T21:08:44.296Z" }, + { url = "https://files.pythonhosted.org/packages/af/35/41b28fa8eb8f1bee9c5516b99262a5df413937bf186e9d50ec3ef812fc61/setproctitle-1.3.8-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:fbd9ceb8c091c125e5892757e4d16af06674dc96edd7b5bf1e627b1ae7650c66", size = 13140, upload-time = "2026-10-01T21:08:46.797Z" }, + { url = "https://files.pythonhosted.org/packages/0c/19/089c878e6b8a3a391ceb03e1653ad6f33f27a5f7e204cf4a54b6a053b83d/setproctitle-1.3.8-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:322b4643f98c824b17dbf2ddb07ea7a37def8cc9e18adee320befce4bdffa271", size = 35122, upload-time = "2026-10-01T21:08:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/88/a7/f419b87fcb6202ef0a94f7cd6bdecb23c2d01f2f07b38f92d3f0c5dfb518/setproctitle-1.3.8-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36ee7300584a53fd7ac5f3853323411bde1cf6c081921bd3754a73b4071245", size = 35944, upload-time = "2026-10-01T21:08:52.612Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/fb181112f3c09a8c8962bd110f538c8a9d287a0c6a8bfd5b4dc39d54e0df/setproctitle-1.3.8-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c05aff52e6eb9c4d2ea86cf0d0a60b1487ed570263a1b13bb30d9f96aa8ba97", size = 37981, upload-time = "2026-10-01T21:08:55.136Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8f/934846cf63b5ae2e9a88dcac6ddc3ec813e6b9e0919994684f7674155b8a/setproctitle-1.3.8-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df807e7f036c19235320b6b5137291891d660f10ccb0b50dc46bc8385be18379", size = 34229, upload-time = "2026-10-01T21:08:57.703Z" }, + { url = "https://files.pythonhosted.org/packages/c9/04/c11f73d8aa0ebe5d63866130ad14ca3789a4dfb9fc617c3a612af9c177f9/setproctitle-1.3.8-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:bef8c5f8820c70e8761171409052056ffd247023b001a6254287a0694c3d8b1c", size = 34743, upload-time = "2026-10-01T21:09:04.088Z" }, + { url = "https://files.pythonhosted.org/packages/38/50/8c780488db26e8f51af91465bda6ee224522248dc5220b282791dacbbe5e/setproctitle-1.3.8-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:7855290194637a3c5a2e37c423efbf5813fde97a6b63ab99feade5e05d73dc6c", size = 36094, upload-time = "2026-10-01T21:09:06.67Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e6/13d541653607b3b7232dc9f8baae05b24112ac9a3756c3653903c624c4a9/setproctitle-1.3.8-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:8c0a7753f5ddaf0ecd1fc8df3e2106611d4290f0508e643e723cb5000b7d6c2f", size = 33148, upload-time = "2026-10-01T21:09:09.064Z" }, + { url = "https://files.pythonhosted.org/packages/42/94/f3e16c3a7949fa28a777aa30830bf89098026e13669144b0ec1b147f17f4/setproctitle-1.3.8-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:97b35b15f81363a314baca76674c19027dce19eed1590abd51c2d285830efd50", size = 34488, upload-time = "2026-10-01T21:09:11.756Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e7/9baa826fc44c6f607a39ad35d419da7f51dec2df31ef169e7a4a34e1cc75/setproctitle-1.3.8-cp315-cp315t-win32.whl", hash = "sha256:ecf44731e351c6100885120cecf53a0f43a1bb975a92480de1f9e022ab542bc1", size = 12260, upload-time = "2026-10-01T21:09:13.6Z" }, + { url = "https://files.pythonhosted.org/packages/71/fb/08f55fab7294d18a6b1bc6d13340706c998b4641bd5f99ed9c7a626fcbf0/setproctitle-1.3.8-cp315-cp315t-win_amd64.whl", hash = "sha256:61aac898128dade36438da9387fea570f00cd519ad5231d3b268913afc2fc329", size = 13170, upload-time = "2026-10-01T21:09:15.351Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e1/61838733db1ee33c4d550e72e98891824b28f644a2fb9612f107e5bf819d/setproctitle-1.3.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:26da5ad9883d825b9d732fd1dcb99268a4ee545b1edd0aaa5c56f16009cccaf8", size = 12627, upload-time = "2026-10-01T21:09:21.435Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cf/08599efcb67f437acc0b1b6510aa08f44873cbd416b526139c4eb38b2775/setproctitle-1.3.8-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:15aaec002066f6dd7d920c46d3bffc3e529cb4a9fac961f2dd878aad64b0e29e", size = 14324, upload-time = "2026-10-01T21:09:23.204Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a1/971a49677f247a613a2229df65af01d5a27e8793adee5b818680e90c30a9/setproctitle-1.3.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6a740a6de2675da29594612eca3c962ceefc063fb62f43fc5a8f84d547ce0d7a", size = 12982, upload-time = "2026-10-01T21:09:25.006Z" }, +] + +[[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 = "textual" +version = "8.2.8" +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/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + +[[package]] +name = "tomli" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6", size = 17662, upload-time = "2026-10-07T12:23:37.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/ab99b60ee52acd949684febabc3005d0045d0f66bebd9cdebd67372d26dd/tomli-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545", size = 163901, upload-time = "2026-10-07T12:22:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/bc/00/ee01b7ed4579180fff07142d290257f25ba786f23f3ec6005f620933c2f5/tomli-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef", size = 163756, upload-time = "2026-10-07T12:22:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/72/c2/4efebf65372f6583185f79799312109dddb61102d47e5c33dcfd1a297aca/tomli-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b", size = 268038, upload-time = "2026-10-07T12:22:18.135Z" }, + { url = "https://files.pythonhosted.org/packages/53/07/5850468e925d898abb36038666f9c333a94d2a223e802a8ba5b6d319d23f/tomli-2.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56", size = 276422, upload-time = "2026-10-07T12:22:19.567Z" }, + { url = "https://files.pythonhosted.org/packages/b4/87/f293984cdcf83c054196d4fd3dad44fc68ae55b4b8c44bc76cef360c3150/tomli-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1", size = 272616, upload-time = "2026-10-07T12:22:20.794Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ce/db582886b3c1219d3fec93ebd669332482e5aee7a91e0f7838d84f2d1759/tomli-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885", size = 276593, upload-time = "2026-10-07T12:22:22.12Z" }, + { url = "https://files.pythonhosted.org/packages/bf/72/7619b87dea4261fc27dd7b54c4461c129c1f7d9bb7ba3aec89c797a431b8/tomli-2.5.0-cp311-cp311-win32.whl", hash = "sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e", size = 101830, upload-time = "2026-10-07T12:22:23.651Z" }, + { url = "https://files.pythonhosted.org/packages/1e/74/220106da34502304b6751a2a9b8a9fbca6c3fd47e737a2e2e3da7c61c9db/tomli-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8", size = 112742, upload-time = "2026-10-07T12:22:24.972Z" }, + { url = "https://files.pythonhosted.org/packages/27/99/7d9c8b41837a7773613e169504147375c157a290167aa59ad74a085f521f/tomli-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980", size = 109332, upload-time = "2026-10-07T12:22:26.117Z" }, + { url = "https://files.pythonhosted.org/packages/52/ed/7baa86f87493646a594de388c7c1c40a39dd0461f7e9c0359cbeefc91fe8/tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df", size = 164854, upload-time = "2026-10-07T12:22:27.444Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b1/44c0341f2224397855723c7a8a39f718ea6fcbcc3dacc66e5aeca0f334e3/tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b", size = 164074, upload-time = "2026-10-07T12:22:28.679Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e2d5b7d3fba47adedb23de616c16d428ea076c79a3d8e1d95d649ffe197e/tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0", size = 274274, upload-time = "2026-10-07T12:22:29.804Z" }, + { url = "https://files.pythonhosted.org/packages/43/90/6090e706ff27a6f89f4a40578e3324b95c3cd8c4150868aabf33a8f414c3/tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6", size = 286435, upload-time = "2026-10-07T12:22:31.297Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9e/a2c40768df16c408f22430afb0a73e9d7e5f79c950884954649d1146b74d/tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc", size = 278119, upload-time = "2026-10-07T12:22:32.601Z" }, + { url = "https://files.pythonhosted.org/packages/12/25/3c0cb485b98e9cfac495629b1c93c87ccf0b72fbe9d2689fd8fe62c6d5a3/tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7", size = 286177, upload-time = "2026-10-07T12:22:33.745Z" }, + { url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760, upload-time = "2026-10-07T12:22:34.887Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722, upload-time = "2026-10-07T12:22:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534, upload-time = "2026-10-07T12:22:37.296Z" }, + { url = "https://files.pythonhosted.org/packages/42/e5/51736d70da209350969e15aca5c5ab6e2ce1ea87a0a892a6c13aec172a86/tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea", size = 163328, upload-time = "2026-10-07T12:22:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/086f80dab4ab497602644274e6dea7ec5dd0b4e262e443a8ad3bb7edee2d/tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043", size = 162246, upload-time = "2026-10-07T12:22:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/aa/eb/3ecc94459f3635c92321f4e7bde571323fdb2267c50e19e3188a281eae3b/tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0", size = 272655, upload-time = "2026-10-07T12:22:41.08Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d7/494fd1f0c37a621f1ad9975c2efadb523e8101f144ed6edb2e7fe64738f2/tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b", size = 283595, upload-time = "2026-10-07T12:22:42.222Z" }, + { url = "https://files.pythonhosted.org/packages/70/51/bb8d62b1317e6640866f6949b2d5855e5300f2c99d46de1cd245570bba65/tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066", size = 276253, upload-time = "2026-10-07T12:22:43.625Z" }, + { url = "https://files.pythonhosted.org/packages/66/f4/f46bd7f0763cd47de2db697dca9257c6a4adfd1a93b018cc75c8190ed5a8/tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b", size = 283582, upload-time = "2026-10-07T12:22:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628, upload-time = "2026-10-07T12:22:46.508Z" }, + { url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301, upload-time = "2026-10-07T12:22:47.647Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744, upload-time = "2026-10-07T12:22:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/caf6b307766eb9567664a8707e9d6be5fcc0e8903f18781c6677a60d80c7/tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105", size = 162899, upload-time = "2026-10-07T12:22:50.088Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c8/68cfce773a2733a49c74f99d627fb461bd990756860099eac25617889585/tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646", size = 162080, upload-time = "2026-10-07T12:22:51.558Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b2/e5bb8651fdad593f670501a7d718b1a7f73f064d44dea15e04c04dfef45d/tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b", size = 273380, upload-time = "2026-10-07T12:22:52.918Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d2/9e2d7f8b1dfe0e2b34c245986ebd55c4c553ea4ce6c47c443b332673253f/tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75", size = 283228, upload-time = "2026-10-07T12:22:54.173Z" }, + { url = "https://files.pythonhosted.org/packages/ba/df/ec7b876b7b1a2718bd74a3743c076fff565b04029ba33e8f61fac262739f/tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb", size = 277189, upload-time = "2026-10-07T12:22:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/e192d9eed0b9cb80da799f4d77052297fb9a2c3cc9b19f571f56ea88add6/tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3", size = 283632, upload-time = "2026-10-07T12:22:56.735Z" }, + { url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535, upload-time = "2026-10-07T12:22:58.084Z" }, + { url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621, upload-time = "2026-10-07T12:22:59.2Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572, upload-time = "2026-10-07T12:23:00.479Z" }, + { url = "https://files.pythonhosted.org/packages/b9/20/1f88f19427d380a40e90a770e087489eaafe4aeee070ae88ed2bbec00acd/tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4", size = 171814, upload-time = "2026-10-07T12:23:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/cbe5079c9f9a54b9b3e27fc82f08f3cb36edee75561679f53d2380c801d6/tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d", size = 171324, upload-time = "2026-10-07T12:23:03.18Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/1d53fd3b0f1cb3ba542e345ec32c26aefdddc4e829e4f3429af8a4f27782/tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9", size = 297441, upload-time = "2026-10-07T12:23:04.345Z" }, + { url = "https://files.pythonhosted.org/packages/66/d9/0800acb6a111686f764c1b91ef15cc42a20a66a46013bb42220f1d2c61c1/tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f", size = 307476, upload-time = "2026-10-07T12:23:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/30a8f3cd51b5bec37f04744bad0b0dc6160df84aad4f27b0e9283d66f221/tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374", size = 296113, upload-time = "2026-10-07T12:23:07.202Z" }, + { url = "https://files.pythonhosted.org/packages/ab/18/0b9ffc597e69c5a1e20a7823cb60d54b39a9f54e91edcb8574f022186758/tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442", size = 307725, upload-time = "2026-10-07T12:23:08.508Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546, upload-time = "2026-10-07T12:23:09.956Z" }, + { url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814, upload-time = "2026-10-07T12:23:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188, upload-time = "2026-10-07T12:23:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc", size = 162775, upload-time = "2026-10-07T12:23:13.941Z" }, + { url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276", size = 161406, upload-time = "2026-10-07T12:23:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52", size = 273855, upload-time = "2026-10-07T12:23:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7", size = 284910, upload-time = "2026-10-07T12:23:18.166Z" }, + { url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391", size = 277723, upload-time = "2026-10-07T12:23:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859", size = 285115, upload-time = "2026-10-07T12:23:20.698Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475, upload-time = "2026-10-07T12:23:21.941Z" }, + { url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589, upload-time = "2026-10-07T12:23:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493, upload-time = "2026-10-07T12:23:24.233Z" }, + { url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57", size = 171380, upload-time = "2026-10-07T12:23:25.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd", size = 170553, upload-time = "2026-10-07T12:23:26.855Z" }, + { url = "https://files.pythonhosted.org/packages/53/aa/3056c919eb3e084df3752b2cf5f865dcc04af0b27dba2f66d7b28af4633a/tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01", size = 294428, upload-time = "2026-10-07T12:23:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/faeeb5d8769ea3832021d73e892c8391eae7b4b4f8b55a789127bd8b18a9/tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f", size = 304909, upload-time = "2026-10-07T12:23:29.381Z" }, + { url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a", size = 293220, upload-time = "2026-10-07T12:23:30.608Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142", size = 305705, upload-time = "2026-10-07T12:23:32.181Z" }, + { url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432, upload-time = "2026-10-07T12:23:33.496Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281, upload-time = "2026-10-07T12:23:34.648Z" }, + { url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069, upload-time = "2026-10-07T12:23:35.77Z" }, + { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" }, +] + +[[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 = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + +[[package]] +name = "uv" +version = "0.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/55/609455d21e87430c11fe734abfced50a3bdf177b2f49d41fbb19c199cbc3/uv-0.13.4.tar.gz", hash = "sha256:b0bff5b9a5061f7a35b1d0c1a94c19af134222c48eb7aadc42f85a038be1b248", size = 7482486, upload-time = "2026-10-20T15:12:06.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/bd/4349da5356b17a1c0e36c8552c809925f86f6292de9b1f599f01e8e8093d/uv-0.13.4-py3-none-linux_armv6l.whl", hash = "sha256:205a4f93e568fb24f45a2f3834f1c0a8f1adb5f396bf473c71030ed707dce048", size = 18272390, upload-time = "2026-10-20T15:11:02.468Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/0cb77a1ae43959772f8f7873eb1f4061907fbb1694ce55c79b231f639b1d/uv-0.13.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b4bbacea78c9db136cd2ec3147cbfd9ed910b4db5e1877109bd74d0e73bb2eaf", size = 17384113, upload-time = "2026-10-20T15:11:07.172Z" }, + { url = "https://files.pythonhosted.org/packages/26/44/5a8733dd1b2e8d6f9cffd1ff6428a3e11dcc7e146a7657e32cfb8d9fb4a4/uv-0.13.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:95f4eb2b991b53b50ec2b18ab612fca8ba1e7a75a47af0331fb8bcd21da3eb03", size = 14433723, upload-time = "2026-10-20T15:11:10.185Z" }, + { url = "https://files.pythonhosted.org/packages/f8/39/be14f051bf34a1822d634819890a2d0cba3b69101e633fb7b9f54d7b4020/uv-0.13.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:e90ab42825587821096cb0d071e387468da38cfa22818c91f9c0b3c0453048e8", size = 17816443, upload-time = "2026-10-20T15:11:13.674Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/5df0f8bc6367dd37b98432a7c462e2918554f6db2a71211574852b8f0727/uv-0.13.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:3a9dcb6762c3ba249d8c084315ba03839082af3ece3536cc4164ec735c351d68", size = 17828025, upload-time = "2026-10-20T15:11:16.806Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9c/2fed5357700b7c304b94214979e4efe680c3bc6e337a97eedaa372fe0947/uv-0.13.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ae6ff2b07892379dc767c9064c2b8bbfac88e767edac7ce28e1b0f15d86abb4", size = 17843737, upload-time = "2026-10-20T15:11:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/4c/2e/ee026284f096236dc3e1abed0316f9edab65c0ea176ea0072c064e62478d/uv-0.13.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25cc14ff4fd8335d3be2cc493a621a003fdafcb0fb1c953c905daabe4def5ab8", size = 18576350, upload-time = "2026-10-20T15:11:24.035Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f1/67fbdb649c8c5d0cb38ea74829080479b77c8631cda5d77fc1b28f027ca7/uv-0.13.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:77062b02856c74697f48328d8a6583ab07e6da0a993e86c2db46f3d1da27c637", size = 19831234, upload-time = "2026-10-20T15:11:27.627Z" }, + { url = "https://files.pythonhosted.org/packages/bd/00/7d49e3a1a3e98e5244fa38e0c964dc8de568bf3bb101257a63dc2602efed/uv-0.13.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fb0e2028d9a8929884344afb7d91d0f65d9783f640d28aede89fca3602bedde9", size = 19702886, upload-time = "2026-10-20T15:11:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a1/de02c0913726728eca56ee258875cbba9c7b0572c63f494fd9f55c7852e3/uv-0.13.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05e4332791b4d55b6dde8695b7fa6079b26991519f44760887c5dfbd5ca4fe53", size = 17181168, upload-time = "2026-10-20T15:11:34.583Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cd/f4a3c7377674366798c56aabd0f9a1f05687c3939f5ba857a3ea6093ff84/uv-0.13.4-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7b3cdf868e5415631fe797b648c0d6945d38d388f23367bdd0b6332e80a1fcb0", size = 16502020, upload-time = "2026-10-20T15:11:37.88Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f4/ce79657c5a0f51b602db9a663529baafff03b9fe8cc41e09ad3f7246b88a/uv-0.13.4-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:4e6b08578c920b86910897f36bb6da7229c8e7e0187976570e2a538cb28b777e", size = 18000980, upload-time = "2026-10-20T15:11:41.59Z" }, + { url = "https://files.pythonhosted.org/packages/21/e6/7d77022d5661f8e01578bc5423bf02af408220e7e7ac5ee15eb94742c7cb/uv-0.13.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d86a5dec8ab30c70519c71ca5085c8aaf28664836cbc600951cedaa3fc678cbf", size = 18179083, upload-time = "2026-10-20T15:11:45.191Z" }, + { url = "https://files.pythonhosted.org/packages/f4/40/1bf0324800d38009774ba087a6fbffddc76a3e5bf42c081b9298b1236fb5/uv-0.13.4-py3-none-musllinux_1_1_i686.whl", hash = "sha256:5d89d1028c2e22977d9215cbb79cfe12559cb0c8a507a12e2f3a7e47930cf1bf", size = 18010389, upload-time = "2026-10-20T15:11:48.963Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ec/f352ca2cc3b4b6191bb4ec8cdf4bc190eed0ba8bda4c33b7fd66c5d33cec/uv-0.13.4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:52b5c753a7c2d5f8323a70bb4c93118988356331d0758afa6446c81f974e7196", size = 19004998, upload-time = "2026-10-20T15:11:52.628Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4f/fc05ff4292edfe785b47874ec86d3d83ca2bf735cff8a9d2170ffb66ad83/uv-0.13.4-py3-none-win32.whl", hash = "sha256:d333f01852b817ba05612de31474739da1af7e5463d119b30a9c4331eba1f408", size = 16190502, upload-time = "2026-10-20T15:11:56.657Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b9/b526eab6c72eacc705a58378f6850fde046b28c86fed01bd42da61367c6b/uv-0.13.4-py3-none-win_amd64.whl", hash = "sha256:b2ff80397273fff0e02c20eacae6882834c811827d6fc08f658db156fe754386", size = 15331833, upload-time = "2026-10-20T15:11:59.733Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f7/4d5fa89d3d10ff6a84e547ef6e02c45b2f2d50e66682da778acf60db63f1/uv-0.13.4-py3-none-win_arm64.whl", hash = "sha256:deffa5a4afdb48f3e44fb26e80377d256b330ddcad0373daff4b54f6162b43a4", size = 15690151, upload-time = "2026-10-20T15:12:02.74Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.14.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/04/2f69ba1207ce59630ecea8ebfdaf5c8f9a14726f2408ccd145a18d1ef619/virtualenv-21.14.9.tar.gz", hash = "sha256:4d1bee354cd81c820da3bee56e38be3426490f8d1fc54e10ea5b2072ebad8ef8", size = 5500672, upload-time = "2026-10-17T16:09:42.345Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/e5/9a3918219745e8bdd64fe0fd9a2d7ce4fffde9268fbc791634b7d4779de9/virtualenv-21.14.9-py3-none-any.whl", hash = "sha256:ac51744da66ae56ff814b087d65096c94de698ec520adc624b108b679655809b", size = 5494452, upload-time = "2026-10-17T16:09:40.584Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/b4/7830542634bb2d3e62aa3b586a72d5b3b6c91c3168929e7000ef3fed041d/wcwidth-0.9.2.tar.gz", hash = "sha256:ae0ef90b90f6af38b54f1fe6d58662ec33b3cb4b8391958a62416d654231727b", size = 955039, upload-time = "2026-10-05T00:24:05.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/1e/4532a81fb9dfbf4114a816775e0a36c3a64ee1d1f4bba2094e2da50be5dc/wcwidth-0.9.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7ef5a940bd5e30bac6e721f1a48fce0cd7bb3ece19e9c5d139e72c76c35cfd07", size = 605343, upload-time = "2026-10-05T00:23:22.649Z" }, + { url = "https://files.pythonhosted.org/packages/a0/07/cb6940e81134b7ed25fa312ee9ab536a63db0793b149f88a90e603ceace9/wcwidth-0.9.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ae0800c5339423cc53d33a266ad264b42ba8aaa16d4464f6e6b1bee607f50b17", size = 609960, upload-time = "2026-10-05T00:23:27.049Z" }, + { url = "https://files.pythonhosted.org/packages/a4/80/15ad05d40bfa99155639fb9e13b3d77083aa0fab893c816db2543d29005c/wcwidth-0.9.2-cp310-abi3-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:9e542f1f8475b78452a295495d7a5bc3ead565112e9446a64dc93462a41c2a79", size = 762566, upload-time = "2026-10-05T00:23:38.322Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/b8ef7758003d66b60f093695831a86dcc726aac01ee6446ffcbda27b61e3/wcwidth-0.9.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:674b518af28d38ee645ff97b74f5760abee5fad4bac74413bfc4b881ef2ce724", size = 771632, upload-time = "2026-10-05T00:23:32.448Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/f940133c71427c208575910e981942bd78c98b1f7cd0d1425ca4b7457c04/wcwidth-0.9.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:751bef0ab404b6a1dc028b56b4b85d46486be1c55833f80da533e42dc691f389", size = 769763, upload-time = "2026-10-05T00:23:40.175Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/285f862826f721964ec7c42f81dc53d23afbd723a0f4cd989651f8218e25/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c3d80f39ba4653a595edae9aa46a509d14883790a8fc23c5db221ceb207f64b7", size = 782134, upload-time = "2026-10-05T00:23:33.926Z" }, + { url = "https://files.pythonhosted.org/packages/c2/2d/64aa54882a5d556d3654c1f926d9118b797461033e23a158409941a37c8f/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:0a47e03d8293590ecce66c45dc20ff7b4b885e3c78093722239585eca0d77ab2", size = 782851, upload-time = "2026-10-05T00:23:41.974Z" }, + { url = "https://files.pythonhosted.org/packages/59/39/52389f6de7fe2e9c14ceb8253dd99034bd86e1c87847ea3c100a97dded9a/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:67d901a4ad99249eb775b4ee4769ca97fa405d35a75f46e83166910a47003f04", size = 784621, upload-time = "2026-10-05T00:23:43.449Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/20225500a076ace27bbcc8a6fd7c55125133c57a618816c7b7b8b73070b1/wcwidth-0.9.2-cp310-abi3-win32.whl", hash = "sha256:ee1fd0db9d9fd711a70f3e7765e0e04c05d26982fa05361456163062549d7da4", size = 593123, upload-time = "2026-10-05T00:23:55.953Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/b0690f55ea0483530a18bac917fbadbf54f35122510446fc370f5f1c2453/wcwidth-0.9.2-cp310-abi3-win_amd64.whl", hash = "sha256:2a9746de704242bd4fdaabb31dd46b82f694a56a8d21081ad89b679a89da9fec", size = 597827, upload-time = "2026-10-05T00:23:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/e5/11/6ecf4e9e268ab1a4ec617ffcccc2ee4a71301625f5490912dbaba462fa9c/wcwidth-0.9.2-cp310-abi3-win_arm64.whl", hash = "sha256:b9c6ab615e03723b7f8760ea2f27758d656e7e13b51515c9dca5c3e8b04612fa", size = 598415, upload-time = "2026-10-05T00:23:51.517Z" }, + { url = "https://files.pythonhosted.org/packages/4e/41/549eef1ab767032bdbdc1f0ab655d404b082b1e9a1dab1361dbba90f64ed/wcwidth-0.9.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eda88ffdc97c0fbf193d407114f2c7a54b379f67f6e52a7531ee3b9fe749eca7", size = 606555, upload-time = "2026-10-05T00:23:24.188Z" }, + { url = "https://files.pythonhosted.org/packages/9b/64/a875ed7ea71cacadc0ae11b5fd3fac3486efd58bb25e67a7344248dceadd/wcwidth-0.9.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf361c8705576760623b4724ae564666d73b016f9a778bcfd1c7345378ef4ec", size = 610829, upload-time = "2026-10-05T00:23:28.563Z" }, + { url = "https://files.pythonhosted.org/packages/c6/98/513095e484fe79b6f2613d6a72f855f5d56b65e15c215c2a6746fbc638f5/wcwidth-0.9.2-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:97b878d1e158da5ed9ac5aac53fa3a55e282103af6a09ec353865613d1a31a76", size = 775819, upload-time = "2026-10-05T00:23:45.116Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/c02f3eec57224731e78f84b68e272250f784b6205acc7e0dcef6a7c23a0e/wcwidth-0.9.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:59dab4049cbd982b478bca098528df2c79a9160636a3a163ffebffcbd7d1b892", size = 786915, upload-time = "2026-10-05T00:23:35.323Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/b0529a79bac3fe8d94f32b4237a13dbc3f955508753f6a6f06c73d679dc2/wcwidth-0.9.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb08ceb501d6aaf94066c3ee122dd825b152df40ff0bd0df4dc27126233b948e", size = 784186, upload-time = "2026-10-05T00:23:46.366Z" }, + { url = "https://files.pythonhosted.org/packages/d5/bd/6357c84ca9a734bfc735b7c48dbe21336b3777fab8a4101d14976dfe49a7/wcwidth-0.9.2-cp314-cp314t-win32.whl", hash = "sha256:8b4e381590b9b7390e07e22b2c0c1bb96ce50e1d2243c866d9387600362d51ed", size = 600791, upload-time = "2026-10-05T00:23:59.398Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/037591ca18d897cc2179559dde72e6efc6ce0c90e9cd1e6bca4e87c38b4b/wcwidth-0.9.2-cp314-cp314t-win_amd64.whl", hash = "sha256:f2f7b3bba5a5d5f31fc350fd36ce5b84b693c83b7eb95ee630b720da5a5ce06f", size = 605463, upload-time = "2026-10-05T00:24:01.049Z" }, + { url = "https://files.pythonhosted.org/packages/d0/07/c9d96e106d938d26f7ab639bc80b8199359a1645ba6e3498413313ab6f38/wcwidth-0.9.2-cp314-cp314t-win_arm64.whl", hash = "sha256:734aa9405b321d1042301aa19c943c4731ee9e3460e4f8feea3299c064c97a14", size = 605936, upload-time = "2026-10-05T00:23:52.765Z" }, + { url = "https://files.pythonhosted.org/packages/82/8a/a28d61d910005ac93dfe48be3a0ebaa49352d88cebd25323e69e6ff2f4a8/wcwidth-0.9.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:42dbcb76ce8af39e2c9db410ac3f9bdf4e47eb41d6f44525952f172d3d98f724", size = 606575, upload-time = "2026-10-05T00:23:25.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/c2/a3c66bd32766c8f4d6dc47d572532ba014fe5be30489f2576aff7cada363/wcwidth-0.9.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:138e1f8898e431b2f2d7881f8ca8d75591c1d3c21aa53f54e989bd6b39811da2", size = 611045, upload-time = "2026-10-05T00:23:30.421Z" }, + { url = "https://files.pythonhosted.org/packages/ec/8a/d39964f8f8c019d7d439b9b501d3e7bb42fee69f00354040ba0b27b5824c/wcwidth-0.9.2-cp315-cp315t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5175609bf8cc7398a5f48aa35207bd64ebf9f45e4c70df65f7fdc7a988041a3c", size = 778143, upload-time = "2026-10-05T00:23:47.7Z" }, + { url = "https://files.pythonhosted.org/packages/2f/53/525da13e8f9ff7b5b4e74ec6f8d68bdee63905796972e086c6b1b96670d2/wcwidth-0.9.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5f669ae8c3d969c72032f9cdee019674b666e522d45e1e2099a2e9dda4a341d", size = 786630, upload-time = "2026-10-05T00:23:36.967Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9f/d6a0c6df354b9d93466548a65cbf4ffcb48c719bbd307504cf3e76740837/wcwidth-0.9.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:196b47cf32f9df27ccda6dc513237f3c2429c4c659db428d60a5bc443d10f270", size = 784839, upload-time = "2026-10-05T00:23:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d7/3021feed1ed7926021ec134943ad3b24a2f7ea742cc9976461171482ed77/wcwidth-0.9.2-cp315-cp315t-win32.whl", hash = "sha256:0cd4f7f2e53905dcb110d213a4c8529b6733fa3d232d8c717f946cc69a10349b", size = 600845, upload-time = "2026-10-05T00:24:02.497Z" }, + { url = "https://files.pythonhosted.org/packages/63/80/6a03356d8ee38261e3a78cf89ee03d8e7f12c572d969237be00869e2dc73/wcwidth-0.9.2-cp315-cp315t-win_amd64.whl", hash = "sha256:33df042f96c61ed3cd5fb3742fba427553a635bc578799857a48aa79f774a0b9", size = 605441, upload-time = "2026-10-05T00:24:04.052Z" }, + { url = "https://files.pythonhosted.org/packages/0c/48/1a308a86a833fd12ff7a08d0d2491ff4a72c8a92d12f5ead8317630f771e/wcwidth-0.9.2-cp315-cp315t-win_arm64.whl", hash = "sha256:48719a9bc76c2f84238693fe5013571fa5beffa3621cf228f1f3a9e30dae84b8", size = 605885, upload-time = "2026-10-05T00:23:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b4/0bfa065af506540d9d558e3e5548cff00bc1f9b24e6e2a8512498e8628de/wcwidth-0.9.2-py3-none-any.whl", hash = "sha256:89ca642c5bf0101157a09366be69fad0379db1f700ae39a920e103234573670e", size = 301667, upload-time = "2026-10-05T00:23:21.097Z" }, +] diff --git a/justfile b/justfile index 08916d1..87da995 100644 --- a/justfile +++ b/justfile @@ -10,11 +10,16 @@ default: # `test` is the full generation matrix: give it a roomy TMPDIR (the default 4G tmpfs # /tmp can overflow) — see "Run every gate" in AGENTS.md. Scanners and the changelog check # stay CI-only (off `ci`). -ci: fmt-check lint typecheck test policy audit +ci: fmt-check lint typecheck test policy audit automation-ci @echo "ci: all gates passed" verify: ci +# The independently generated helper keeps its own lockfile and complete gate. +automation-ci: + uv sync --locked --project automation + just --justfile automation/justfile ci + # Run the template generation + update tests test: uv run pytest diff --git a/tests/policy/test_automation.py b/tests/policy/test_automation.py new file mode 100644 index 0000000..2bf8dd5 --- /dev/null +++ b/tests/policy/test_automation.py @@ -0,0 +1,34 @@ +"""Keep the helper's generated gates on the maintainer delivery path.""" + +import re +from pathlib import Path +from typing import cast + +import yaml + +ROOT = Path(__file__).resolve().parent.parent.parent + + +def test_automation_gate_is_required_locally_and_in_ci() -> None: + justfile = (ROOT / "justfile").read_text() + ci = re.search(r"^ci: (.+)$", justfile, re.MULTILINE) + assert ci is not None + assert "automation-ci" in ci.group(1).split() + recipe = re.search(r"^automation-ci:\n((?: .+\n)+)", justfile, re.MULTILINE) + assert recipe is not None + assert "uv sync --locked --project automation" in recipe.group(1) + assert "just --justfile automation/justfile ci" in recipe.group(1) + workflow = cast( + "dict[str, object]", + yaml.safe_load((ROOT / ".github" / "workflows" / "test-template.yml").read_text()), + ) + jobs = cast("dict[str, object]", workflow["jobs"]) + assert "automation" in jobs + job = cast("dict[str, object]", jobs["automation"]) + assert "continue-on-error" not in job + steps = cast("list[dict[str, object]]", job["steps"]) + assert any( + step.get("run") == "just automation-ci" + and step.get("env") == {"TMPDIR": "${{ runner.temp }}", "HYPOTHESIS_PROFILE": "ci"} + for step in steps + ) diff --git a/workflow/SKILL.md b/workflow/SKILL.md new file mode 100644 index 0000000..0f2897c --- /dev/null +++ b/workflow/SKILL.md @@ -0,0 +1,49 @@ +--- +name: python-kickstarter-workflow +description: Prepare and verify maintained Python changes with Copier. +compatibility: Python 3.11+, Git, Copier, uv and just on Linux or macOS. +metadata: + version: 0.1.0 + author: Ashlen (maybebyte), Hermes Agent + license: ISC +--- + +# Python Kickstarter workflow + +Use this procedure before editing maintained or shipped Python, and when reviewing its delivery evidence. It supplements the host's implementation contract; that contract still determines edit, commit, publication and installation authority. Disposable one-off scripts are exempt until they become maintained or shipped. + +The deterministic helper lives in [`../automation/`](../automation/README.md). Read its current interface reference before invoking it with the host's terminal tool. Resolve linked paths relative to this file, not the product's working directory. Installation and normal-entrypoint routing are separate work: see [host routing](references/host-routing.md) only when that work is authorized. + +## Prepare before production edits + +1. Recover the product's behavior, source root, identity, license, Python compatibility and application/library role from its repository. Record the task's authority and declare the real product slice, including its tests, fixtures, dependencies and packaging context. Ask only when consequential intent cannot be recovered. Finish when every required input has evidence or a specific blocker. +2. Select the workspace branch: generate a new project's authorized destination; reuse an existing generated project's configuration in an isolated task checkout; otherwise generate an external workbench. Permanent adoption in an existing repository needs separate authority. Resume only validated same-task state; different tasks require separate mutable workspaces. Finish when ownership and source/test mappings are recorded. +3. Select a reviewed exact template commit and record the canonical source, resolved revision and tool identities. Review executable template features before use. Skip generation tasks and perform controlled environment setup; validate Git ancestry independently so a home-wide ancestor cannot become the scan root. Preserve the generated answers file. Existing projects keep their pinned template revision. Finish when the helper reports preparation complete, not merely when a directory exists. + +Setup permission covers task-local development dependencies, lock/sync and environments. Installing, activating or modifying hooks requires separate authorization, including in generated projects. Global installs, spending, changed runtime dependencies, template upgrades and live skill rollout are separate decisions. Read-only diagnosis can continue while setup is blocked. + +## Develop and verify the actual product + +1. Preserve generated template CI and every mandatory repository gate. Choose additional scanners, developer-dependency auditing and mutation diagnostics from the changed risk before editing. Keep thresholds and established exceptions. If required gates need broader repairs, present that concrete scope decision rather than weakening the gate. +2. Develop through the host's test-first contract. Test public behavior, retain new regressions in the maintained product suite, and assess meaningful generated-input properties where supported. Adapt measurement paths to the real product rather than accepting scaffold-only tests or empty scans. Finish each slice with observed regression sensitivity and passing affected checks. +3. Invoke the helper's verification operation. It owns command execution and receipts; an agent-written success marker is not evidence. Check actual product test execution, coverage targets, tool/configuration identities and candidate bytes/modes. Reject missing, failed, interrupted or stale required results. Distinguish an empty runtime audit from an audit of developer tools. +4. Obtain independent review of both the candidate and the selected gate plan. Supply the complete staged/unstaged/new-file candidate, source identity, requirements, test/property disposition and raw receipts. Reviewers must challenge inadequate measurement and omitted mandatory checks rather than accepting a green summary. + +**Verification complete:** required checks pass or have an explicitly approved scoped exception, the evidence still matches the reviewed candidate, and reviewers have no unresolved blocking findings. A workbench pass does not establish destination validity. + +## Return and deliver + +1. Build the reviewed product-file allowlist. Keep generated scaffolding, lockfiles, hooks, CI and reports out unless their adoption is authorized. Verify the source stayed unchanged by workbench development and compare destination state immediately before transfer. +2. Use the helper's authorized return operation within the host assignment's scope. Compatible destination drift requires a refreshed snapshot, intent-preserving reconciliation and rerun checks. If reconciliation changes candidate bytes or modes, have the host's independent reviewer assess the new candidate and bind approval to its new receipt before transfer. Textual merge success and coverage do not establish preserved intent. This review can proceed automatically for compatible changes; behavioral conflicts or unclear ownership require a user decision. Never use an old receipt to overwrite newer files. +3. Rerun required destination-layout checks. Report verification, transferred files and destination validation separately. On interruption, retain diagnostics and inspect actual partial effects before resuming; do not blindly repeat writes or describe an uncertain outcome as success. +4. Follow the host's authorized delivery endpoint. Keep a compact external project record with validated mappings, template/tool identities, rerun instructions and the latest accepted receipt. Retain unresolved workspaces. Cleanup is explicit; durable records must survive scratch expiry. + +**Delivery complete:** the exact reviewed payload reaches the authorized endpoint, required destination checks support it, and remaining human gates and untested scope are stated. This workflow neither grants merge/deployment permission nor makes tool-level bypass impossible. + +## Reviewer checklist + +- Does preparation select the right project branch without an implicit migration, update or hook installation? +- Do tests and scanners exercise the declared real product slice, with unchanged mandatory gates and an evidence-backed property disposition? +- Do receipts bind the final candidate, relevant tests/configuration, tooling and measured paths rather than a prior version? +- Does the reviewed allowlist preserve newer/unrelated destination files, with fresh evidence after reconciliation and honest partial-transfer reporting? +- Are code readiness, installation and observed fresh-native-agent discovery reported separately? diff --git a/workflow/references/host-routing.md b/workflow/references/host-routing.md new file mode 100644 index 0000000..ce270c8 --- /dev/null +++ b/workflow/references/host-routing.md @@ -0,0 +1,37 @@ +# Route host entrypoints to the shared workflow + +This is rollout guidance, not an installer. Keeping these files in the Kickstarter repository does not activate them in Hermes, Claude Code or Codex. A code-only implementation assignment leaves installed skills unchanged. + +## Before an authorized rollout + +1. Resolve the active host profile, actual skill paths, symlinks/bind mounts and managed plugin ownership. Inventory all consumers of shared files before changing them. Preserve unrelated profiles and managed plugin caches. +2. Read the installed implementation and review contracts. Keep their authority rules intact: a host that requires permission before committing must still require it; a host that authorizes an assigned task through a PR must not gain new routine approval stops. +3. Select one reviewed checkout/version of this repository as the shared source. Keep `workflow/` and `automation/` together, or supply an explicit verified helper location when packaging them. Copying only `SKILL.md` breaks its relative references. +4. Adapt the small insertion points below into the existing entrypoints. Do not replace whole skills or copy the workflow body into several competing sources. Review and test the complete installed candidate, including references. + +## Implementation insertion point + +Place the requirement where the host decides its development workspace, before production edits. Replace `WORKFLOW_PATH` with the authorized installation's resolved path during rollout, not with a machine-specific path in this repository. + +> For maintained or shipped Python, read `WORKFLOW_PATH/SKILL.md` and prepare the real product workspace through its helper before editing. Use an external workbench for an existing untemplated repository unless permanent adoption is authorized. Disposable one-off scripts remain exempt until promoted. Verify the exact candidate before delivery and report the helper receipt, tested product scope and destination validation. This routing preserves this host's existing implementation authority, test-first standard, independent review and delivery endpoint. Pass the same workflow and evidence requirements to delegated implementers and reviewers. + +## Reviewer insertion point + +Place the requirement beside the host's requirements and standards review packet. + +> For maintained or shipped Python, read `WORKFLOW_PATH/SKILL.md` and assess preparation, actual product execution, candidate-bound receipts and authorized return against its reviewer checklist. Reject missing/stale evidence, scaffold-only success and omitted mandatory checks. Assess the test/property disposition from evidence rather than inheriting the implementer's verdict. A helper pass does not replace independent code/configuration review or prove native skill discovery. + +## Verify the rollout separately + +Exercise fresh native implementation and reviewer sessions for every supported host and affected shared consumer. Use ordinary task entrypoints without supplying the workflow name, path or expected answer in the task prompt. Inspect actual reads, helper calls and refused inadequate results; an agent promising to use the workflow is not discovery evidence. + +Use disposable cases for maintained Python in an untemplated repository, a justified disposable-script exemption, missing preparation/provenance and a proposed stale or scaffold-only result. Keep each probe's write/publication boundaries explicit. Explicitly loading this skill tests its instructions, not spontaneous routing. + +Record separately: + +- Files installed and the exact version/bytes they contain. +- Native loader discovery of the intended source, with duplicate identities resolved. +- Observed ordinary-task selection and helper behavior for implementation and review. +- Excluded profiles/accounts and untested platforms or consumers. + +A failed discovery test leaves the installation unverified. Preserve the host's earlier contract and recoverable candidate; do not enable hooks, alter managed caches or broaden permissions to force a pass. From 6d86bde0fcf15150ac2e6dd9dfd89f67324a3d63 Mon Sep 17 00:00:00 2001 From: Ashlen Date: Fri, 23 Oct 2026 14:26:01 -0600 Subject: [PATCH 3/7] fix: contain unlimited Semgrep stack limits Cap an unlimited hard stack only inside the scanner subprocess so Semgrep 1.167.0 can run under Python 3.15. Preserve finite limits, valid soft limits and scan inputs; propagate scanner failures. Cover maintained and generated launch boundaries, real clean/finding scans, setup failures and low-stack property-test hosts. --- .github/workflows/test-template.yml | 16 +- CHANGELOG.md | 6 + README.md | 16 ++ automation/justfile | 15 +- .../tests/fixtures/scanner_finite_limits.sh | 14 ++ automation/tests/fixtures/scanner_low_host.py | 42 ++++ .../tests/property/test_scanner_limits.py | 40 +++ .../tests/unit/test_scanner_low_host.py | 50 ++++ justfile | 15 +- template/justfile.jinja | 15 +- ...sha_pin_policy %}scan.yml{% endif %}.jinja | 16 +- tests/fixtures/__init__.py | 1 + tests/fixtures/scanner_bytes.py | 20 ++ tests/fixtures/scanner_caller.sh | 10 + tests/fixtures/scanner_fault.sh | 7 + tests/fixtures/scanner_observer.sh | 9 + tests/test_scanner_limits.py | 235 ++++++++++++++++++ 17 files changed, 522 insertions(+), 5 deletions(-) create mode 100644 automation/tests/fixtures/scanner_finite_limits.sh create mode 100644 automation/tests/fixtures/scanner_low_host.py create mode 100644 automation/tests/property/test_scanner_limits.py create mode 100644 automation/tests/unit/test_scanner_low_host.py create mode 100644 tests/fixtures/__init__.py create mode 100644 tests/fixtures/scanner_bytes.py create mode 100644 tests/fixtures/scanner_caller.sh create mode 100644 tests/fixtures/scanner_fault.sh create mode 100644 tests/fixtures/scanner_observer.sh create mode 100644 tests/test_scanner_limits.py diff --git a/.github/workflows/test-template.yml b/.github/workflows/test-template.yml index 38e56b3..fefd74d 100644 --- a/.github/workflows/test-template.yml +++ b/.github/workflows/test-template.yml @@ -125,7 +125,21 @@ jobs: uv export --frozen --no-emit-project --no-hashes -o requirements-audit.txt uvx pip-audit@2.10.1 -r requirements-audit.txt - name: semgrep - run: uvx semgrep@1.167.0 scan --config .semgrep.yml --metrics=off --error . + run: | + # Bound only this child: Python 3.15 exposes an unsigned RLIM_INFINITY. + # Bash -s uses KiB; 1048576 KiB = 1 GiB, above Semgrep's 1,000,000,000-byte soft target. + # Include equality below: Bash rounds soft-limit reads down to whole KiB. + ( + hard_stack=$(ulimit -H -s) || exit; + if [[ "$hard_stack" == unlimited ]]; then + soft_stack=$(ulimit -S -s) || exit; + if [[ "$soft_stack" == unlimited ]] || (( soft_stack >= 1048576 )); then + ulimit -S -s 1048576 || exit; + fi; + ulimit -H -s 1048576 || exit; + fi; + uvx semgrep@1.167.0 scan --config .semgrep.yml --metrics=off --error .; + ) # gitleaks is installed from the mise.toml pin (single source of truth); # install: false brings up the mise CLI only. persist_github_token # re-exports MISE_GITHUB_TOKEN (v5.1.1+ default is off) so the later diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a2e71c..754455f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 PR-only `changelog.yml` check that fails when `src/` or `pyproject.toml` change without a new `CHANGELOG.md` line; the `skip-changelog` label bypasses it. CI-only, no recipe. +### Fixed + +- Keep Semgrep's native engine working when uv selects Python 3.15: bound an unlimited + hard stack to 1 GiB only inside the scanner child. Preserve finite limits, scanner + pins, rules and targets, and leave the caller and later gitleaks stage unchanged. + ### Changed - Pin uv 0.13.4 in generated mise and CI configuration to use the patched TLS trust bundle. diff --git a/README.md b/README.md index 2d3a27b..e6a9aeb 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,22 @@ All toggles default to `true` — every guardrail layer ships unless you opt out Beyond the local recipes, several toggles also emit GitHub Actions workflows: `enable_scanners`, `enable_dependency_audit`, and `enable_sha_pin_policy` each contribute steps to a generated `scan.yml` (semgrep + gitleaks, pip-audit, and zizmor respectively), `enable_mutation_tests` emits a scheduled `mutation.yml`, and `enable_changelog` emits a PR-only `changelog.yml`. The always-on `ci.yml` runs the `just ci` gate on a Python-version matrix covering every interpreter at or above the project's `requires-python` floor (a single leg for the `3.13` default). +## Scanner resource isolation + +Semgrep 1.167.0 can misinterpret Python 3.15's positive unlimited-stack sentinel. +The scan recipe and CI step therefore give only the scanner child a finite hard +stack of 1 GiB when its inherited hard stack is unlimited. Bash measures `ulimit -s` +in KiB: `1048576` KiB is `1073741824` bytes, enough for Semgrep's existing +`1000000000`-byte soft-stack target. Already-finite hard limits are unchanged; +only an unlimited or oversized soft limit is lowered to fit the new ceiling. +Limit read/set failures stop the scan before launching Semgrep. The caller, +gitleaks and other gates retain their original limits. + +This small Bash block is repeated at the standalone delivery boundaries (maintainer, +helper, generated recipe and CI steps), so generated projects need no new launcher +file and the existing inline CI layout remains intact. Scanner versions, rules, +metrics settings, targets and failure propagation are unchanged. + ## Requirements - Python ≥ 3.11 diff --git a/automation/justfile b/automation/justfile index 3b559b4..7212e00 100644 --- a/automation/justfile +++ b/automation/justfile @@ -45,6 +45,19 @@ mutate: uv run mutmut results scan: - uvx semgrep@1.167.0 scan --config .semgrep.yml --metrics=off --error . + # Bound only this child: Python 3.15 exposes an unsigned RLIM_INFINITY. + # Bash -s uses KiB; 1048576 KiB = 1 GiB, above Semgrep's 1,000,000,000-byte soft target. + # Include equality below: Bash rounds soft-limit reads down to whole KiB. + ( \ + hard_stack=$(ulimit -H -s) || exit; \ + if [[ "$hard_stack" == unlimited ]]; then \ + soft_stack=$(ulimit -S -s) || exit; \ + if [[ "$soft_stack" == unlimited ]] || (( soft_stack >= 1048576 )); then \ + ulimit -S -s 1048576 || exit; \ + fi; \ + ulimit -H -s 1048576 || exit; \ + fi; \ + uvx semgrep@1.167.0 scan --config .semgrep.yml --metrics=off --error .; \ + ) # `git` (not `dir`): scan committed history like CI, catching secrets committed then deleted. gitleaks git . --redact --exit-code 1 diff --git a/automation/tests/fixtures/scanner_finite_limits.sh b/automation/tests/fixtures/scanner_finite_limits.sh new file mode 100644 index 0000000..f52405a --- /dev/null +++ b/automation/tests/fixtures/scanner_finite_limits.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Finite-limit property driver; only the disposable child changes resource limits. +set -eu +ulimit -S -s "$SOFT" +ulimit -H -s "$HARD" +uvx() { + printf 'scanner\t%s\t%s\n' "$(ulimit -S -s)" "$(ulimit -H -s)" >> "$EVENTS" +} +gitleaks() { + printf 'later\t%s\t%s\n' "$(ulimit -S -s)" "$(ulimit -H -s)" >> "$EVENTS" +} +export -f uvx gitleaks +just --quiet --justfile "$1" scan +printf 'caller\t%s\t%s\n' "$(ulimit -S -s)" "$(ulimit -H -s)" >> "$EVENTS" diff --git a/automation/tests/fixtures/scanner_low_host.py b/automation/tests/fixtures/scanner_low_host.py new file mode 100644 index 0000000..d55e72c --- /dev/null +++ b/automation/tests/fixtures/scanner_low_host.py @@ -0,0 +1,42 @@ +"""Run only the finite-limit property after lowering this child's inherited ceiling.""" + +import os +import resource +import sys +from pathlib import Path + +import pytest + + +class Reports: + def __init__(self, output: Path) -> None: + self.output: Path = output + + def pytest_runtest_logreport(self, report: pytest.TestReport) -> None: + if report.when == "call" or report.skipped: + with self.output.open("a") as stream: + _ = stream.write(f"{report.when}\t{report.nodeid}\t{report.outcome}\n") + + +def main() -> int: + root = Path(__file__).resolve().parents[2] + output = Path(sys.argv[1]) + resource.setrlimit(resource.RLIMIT_STACK, (4194304, 4194304)) + soft, hard = resource.getrlimit(resource.RLIMIT_STACK) + _ = output.write_text(f"host\t{soft}\t{hard}\n") + os.chdir(root) + return pytest.main( + [ + "tests/property/test_scanner_limits.py::test_finite_limits_are_preserved", + "--no-cov", + "-p", + "no:cacheprovider", + "--basetemp", + str(output.parent / "child-tmp"), + ], + plugins=[Reports(output)], + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/automation/tests/property/test_scanner_limits.py b/automation/tests/property/test_scanner_limits.py new file mode 100644 index 0000000..c0c0322 --- /dev/null +++ b/automation/tests/property/test_scanner_limits.py @@ -0,0 +1,40 @@ +"""Finite stack limits must pass through the helper's public scan recipe unchanged.""" + +import os +import resource +import shutil +import tempfile +from pathlib import Path + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +pytestmark = pytest.mark.property +_HARD = resource.getrlimit(resource.RLIMIT_STACK)[1] +_MAX_KIB = 2097152 if _HARD == resource.RLIM_INFINITY else min(2097152, _HARD // 1024) +if _MAX_KIB < 1: + pytest.skip("inherited hard stack has no positive whole-KiB domain", allow_module_level=True) +# Keep the usual floor, but leave room to vary both limits on lower-ceiling hosts. +_MIN_KIB = min(8192, max(1, _MAX_KIB // 2)) + + +@given(hard=st.integers(_MIN_KIB, _MAX_KIB), fraction=st.integers(0, 100)) +def test_finite_limits_are_preserved(hard: int, fraction: int) -> None: + soft = _MIN_KIB + (hard - _MIN_KIB) * fraction // 100 + bash = shutil.which("bash") + assert bash is not None + justfile = Path(__file__).resolve().parents[2] / "justfile" + with tempfile.TemporaryDirectory(dir=os.environ.get("TMPDIR")) as directory: + root = Path(directory) + script = Path(__file__).resolve().parents[1] / "fixtures" / "scanner_finite_limits.sh" + output = root / "events" + env = dict(os.environ, SOFT=str(soft), HARD=str(hard), EVENTS=str(output)) + pid = os.posix_spawn(bash, [bash, str(script), str(justfile)], env) + _, status = os.waitpid(pid, 0) + assert os.waitstatus_to_exitcode(status) == 0 + assert output.read_text().splitlines() == [ + f"scanner\t{soft}\t{hard}", + f"later\t{soft}\t{hard}", + f"caller\t{soft}\t{hard}", + ] diff --git a/automation/tests/unit/test_scanner_low_host.py b/automation/tests/unit/test_scanner_low_host.py new file mode 100644 index 0000000..86d81df --- /dev/null +++ b/automation/tests/unit/test_scanner_low_host.py @@ -0,0 +1,50 @@ +"""The finite-limit property must run, not falsely fail, on a 4 MiB host.""" + +import os +import resource +import signal +import sys +import time +from pathlib import Path + +import pytest + + +def test_finite_limit_property_on_four_mib_host(tmp_path: Path) -> None: + before = resource.getrlimit(resource.RLIMIT_STACK) + if before[1] != resource.RLIM_INFINITY and before[1] < 4194304: + pytest.skip("the child-only 4 MiB regression cannot raise an inherited hard ceiling") + script = Path(__file__).resolve().parents[1] / "fixtures" / "scanner_low_host.py" + output = tmp_path / "reports" + log = tmp_path / "child.log" + with log.open("w") as stream: + pid = os.posix_spawn( + sys.executable, + [sys.executable, "-B", str(script), str(output)], + dict(os.environ), + file_actions=[ + (os.POSIX_SPAWN_DUP2, stream.fileno(), 1), + (os.POSIX_SPAWN_DUP2, stream.fileno(), 2), + ], + setpgroup=0, + ) + reaped = False + try: + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + waited, status = os.waitpid(pid, os.WNOHANG) + if waited: + reaped = True + assert os.waitstatus_to_exitcode(status) == 0, log.read_text() + break + time.sleep(0.05) + assert reaped, "selected property child exceeded 90 seconds" + finally: + if not reaped: + os.killpg(pid, signal.SIGKILL) + _ = os.waitpid(pid, 0) + assert resource.getrlimit(resource.RLIMIT_STACK) == before + assert output.read_text().splitlines() == [ + "host\t4194304\t4194304", + "call\ttests/property/test_scanner_limits.py::test_finite_limits_are_preserved\tpassed", + ] diff --git a/justfile b/justfile index 87da995..ec2a16e 100644 --- a/justfile +++ b/justfile @@ -63,7 +63,20 @@ precommit: # Out-of-band secret + SAST scan (semgrep + gitleaks); enforced in CI by the `scan` job, not `ci`. scan: - uvx semgrep@1.167.0 scan --config .semgrep.yml --metrics=off --error . + # Bound only this child: Python 3.15 exposes an unsigned RLIM_INFINITY. + # Bash -s uses KiB; 1048576 KiB = 1 GiB, above Semgrep's 1,000,000,000-byte soft target. + # Include equality below: Bash rounds soft-limit reads down to whole KiB. + ( \ + hard_stack=$(ulimit -H -s) || exit; \ + if [[ "$hard_stack" == unlimited ]]; then \ + soft_stack=$(ulimit -S -s) || exit; \ + if [[ "$soft_stack" == unlimited ]] || (( soft_stack >= 1048576 )); then \ + ulimit -S -s 1048576 || exit; \ + fi; \ + ulimit -H -s 1048576 || exit; \ + fi; \ + uvx semgrep@1.167.0 scan --config .semgrep.yml --metrics=off --error .; \ + ) # `git` (not `dir`): scan committed history like CI, catching secrets committed then deleted. gitleaks git . --redact --exit-code 1 diff --git a/template/justfile.jinja b/template/justfile.jinja index 39c6f66..20822fc 100644 --- a/template/justfile.jinja +++ b/template/justfile.jinja @@ -45,7 +45,20 @@ mutate: uv run mutmut results {% endif %}{% if enable_scanners %} scan: - uvx semgrep@1.167.0 scan --config .semgrep.yml --metrics=off --error . + # Bound only this child: Python 3.15 exposes an unsigned RLIM_INFINITY. + # Bash -s uses KiB; 1048576 KiB = 1 GiB, above Semgrep's 1,000,000,000-byte soft target. + # Include equality below: Bash rounds soft-limit reads down to whole KiB. + ( \ + hard_stack=$(ulimit -H -s) || exit; \ + if [[ "$hard_stack" == unlimited ]]; then \ + soft_stack=$(ulimit -S -s) || exit; \ + if [[ "$soft_stack" == unlimited ]] || (( soft_stack >= 1048576 )); then \ + ulimit -S -s 1048576 || exit; \ + fi; \ + ulimit -H -s 1048576 || exit; \ + fi; \ + uvx semgrep@1.167.0 scan --config .semgrep.yml --metrics=off --error .; \ + ) # `git` (not `dir`): scan committed history like CI, catching secrets committed then deleted. gitleaks git . --redact --exit-code 1 {% endif -%} diff --git a/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_scanners or enable_dependency_audit or enable_sha_pin_policy %}scan.yml{% endif %}.jinja b/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_scanners or enable_dependency_audit or enable_sha_pin_policy %}scan.yml{% endif %}.jinja index 9c9cc11..9079a28 100644 --- a/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_scanners or enable_dependency_audit or enable_sha_pin_policy %}scan.yml{% endif %}.jinja +++ b/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_scanners or enable_dependency_audit or enable_sha_pin_policy %}scan.yml{% endif %}.jinja @@ -40,7 +40,21 @@ jobs: {%- endif %} {%- if enable_scanners %} - name: semgrep - run: uvx semgrep@1.167.0 scan --config .semgrep.yml --metrics=off --error . + run: | + # Bound only this child: Python 3.15 exposes an unsigned RLIM_INFINITY. + # Bash -s uses KiB; 1048576 KiB = 1 GiB, above Semgrep's 1,000,000,000-byte soft target. + # Include equality below: Bash rounds soft-limit reads down to whole KiB. + ( + hard_stack=$(ulimit -H -s) || exit; + if [[ "$hard_stack" == unlimited ]]; then + soft_stack=$(ulimit -S -s) || exit; + if [[ "$soft_stack" == unlimited ]] || (( soft_stack >= 1048576 )); then + ulimit -S -s 1048576 || exit; + fi; + ulimit -H -s 1048576 || exit; + fi; + uvx semgrep@1.167.0 scan --config .semgrep.yml --metrics=off --error .; + ) # gitleaks is installed from the mise.toml pin (single source of truth, kept fresh # by Renovate's native mise manager); install: false brings up the mise CLI only. - uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..8b5cca4 --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1 @@ +"""Native executable fixtures checked with the maintainer test harness.""" diff --git a/tests/fixtures/scanner_bytes.py b/tests/fixtures/scanner_bytes.py new file mode 100644 index 0000000..5117ba1 --- /dev/null +++ b/tests/fixtures/scanner_bytes.py @@ -0,0 +1,20 @@ +"""Native byte-precision observer/launcher for stack-boundary tests, not integration.""" + +import json +import os +import resource +import subprocess +import sys +from pathlib import Path + +if __name__ == "__main__": + if sys.argv[1] == "observe": + limits = resource.getrlimit(resource.RLIMIT_STACK) + _ = Path(os.environ["BYTE_EVENTS"]).write_text(json.dumps(limits)) + else: + resource.setrlimit( + resource.RLIMIT_STACK, + (int(os.environ["SOFT_BYTES"]), resource.RLIM_INFINITY), + ) + result = subprocess.run(sys.argv[1:], check=False, timeout=30) + sys.exit(result.returncode) diff --git a/tests/fixtures/scanner_caller.sh b/tests/fixtures/scanner_caller.sh new file mode 100644 index 0000000..83e0aba --- /dev/null +++ b/tests/fixtures/scanner_caller.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Configure only a disposable caller, then prove the real entrypoint leaves it alone. +set -eu +builtin ulimit -S -s "$INITIAL_SOFT" +builtin ulimit -H -s "$INITIAL_HARD" +printf 'before\t%s\t%s\n' "$(builtin ulimit -S -s)" "$(builtin ulimit -H -s)" >> "$SCAN_EVENTS" +status=0 +"$@" || status=$? +printf 'after\t%s\t%s\n' "$(builtin ulimit -S -s)" "$(builtin ulimit -H -s)" >> "$SCAN_EVENTS" +exit "$status" diff --git a/tests/fixtures/scanner_fault.sh b/tests/fixtures/scanner_fault.sh new file mode 100644 index 0000000..0caec4c --- /dev/null +++ b/tests/fixtures/scanner_fault.sh @@ -0,0 +1,7 @@ +# BASH_ENV fault injection: fail one limit operation, keep all others real. +ulimit() { + if [[ "$*" == "$LIMIT_FAULT" ]]; then + return 73 + fi + builtin ulimit "$@" +} diff --git a/tests/fixtures/scanner_observer.sh b/tests/fixtures/scanner_observer.sh new file mode 100644 index 0000000..61ce289 --- /dev/null +++ b/tests/fixtures/scanner_observer.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# External-process observer for resource/exit tests, not the native integration. +set -eu +printf '%s\t%s\t%s\n' "${0##*/}" "$(builtin ulimit -S -s)" "$(builtin ulimit -H -s)" >> "$SCAN_EVENTS" +case "${0##*/}" in + uvx) exit "$SCANNER_EXIT" ;; + gitleaks) exit "$GITLEAKS_EXIT" ;; + *) exit 99 ;; +esac diff --git a/tests/test_scanner_limits.py b/tests/test_scanner_limits.py new file mode 100644 index 0000000..10a4f21 --- /dev/null +++ b/tests/test_scanner_limits.py @@ -0,0 +1,235 @@ +"""Exercise the scanner recipe, including native-engine success and rejection.""" + +from __future__ import annotations + +import json +import os +import resource +import shutil +import subprocess +import sys +from pathlib import Path +from typing import NotRequired, TypedDict, cast + +import pytest +import yaml + +from tests.conftest import RenderFn, run_in +from tests.test_generation import MINIMAL + +SURFACES = ("maintainer", "helper", "generated", "maintainer-ci", "generated-ci") +FIXTURES = Path(__file__).parent / "fixtures" + + +class _Step(TypedDict): + run: NotRequired[str] + + +class _Job(TypedDict): + steps: list[_Step] + + +class _Workflow(TypedDict): + jobs: dict[str, _Job] + + +@pytest.fixture +def scan_entrypoint( + surface: str, template_root: Path, render: RenderFn, tmp_path: Path +) -> list[str]: + """Drive real just parsing or the actual workflow's shell run block.""" + project = template_root + if surface.startswith("generated"): + project = render({**MINIMAL, "enable_scanners": True}, tmp_path / "generated") + elif surface == "helper": + project /= "automation" + if surface.endswith("-ci"): + name = "scan.yml" if surface == "generated-ci" else "test-template.yml" + workflow = cast( + "_Workflow", yaml.safe_load((project / ".github" / "workflows" / name).read_text()) + ) + bodies = [ + step.get("run", "") + for step in workflow["jobs"]["scan"]["steps"] + if "semgrep@" in step.get("run", "") + ] + assert len(bodies) == 1 + # A later command in the SAME shell is the stricter isolation control; + # actual CI launches its later gitleaks step in a fresh shell. + return ["bash", "-eu", "-o", "pipefail", "-c", bodies[0] + "\ngitleaks"] + return ["just", "--justfile", str(project / "justfile"), "scan"] + + +def _observe( + tmp_path: Path, + command: list[str], + soft: str, + hard: str, + *, + scanner_exit: int = 0, + gitleaks_exit: int = 0, + fault: str = "", +) -> tuple[subprocess.CompletedProcess[str], list[list[str]]]: + inherited = resource.getrlimit(resource.RLIMIT_STACK)[1] + if inherited != resource.RLIM_INFINITY and ( + hard == "unlimited" or int(hard) * 1024 > inherited + ): + pytest.skip("This host cannot raise its inherited finite hard stack for this case") + tools = tmp_path / "recorders" + tools.mkdir() + for name in ("uvx", "gitleaks"): + _ = shutil.copyfile(FIXTURES / "scanner_observer.sh", tools / name) + (tools / name).chmod(0o755) + events = tmp_path / "events" + env = dict(os.environ) | { + "PATH": f"{tools}{os.pathsep}{os.environ['PATH']}", + "SCAN_EVENTS": str(events), + "INITIAL_SOFT": soft, + "INITIAL_HARD": hard, + "SCANNER_EXIT": str(scanner_exit), + "GITLEAKS_EXIT": str(gitleaks_exit), + } + if fault: + env.update(BASH_ENV=str(FIXTURES / "scanner_fault.sh"), LIMIT_FAULT=fault) + result = subprocess.run( + ["bash", str(FIXTURES / "scanner_caller.sh"), *command], + env=env, + capture_output=True, + text=True, + check=False, + timeout=30, + ) + return result, [line.split("\t") for line in events.read_text().splitlines()] + + +@pytest.mark.parametrize("surface", SURFACES) +@pytest.mark.parametrize( + ("soft", "hard", "scanner_soft", "scanner_hard"), + [ + ("8192", "unlimited", "8192", "1048576"), + ("unlimited", "unlimited", "1048576", "1048576"), + ("2097152", "unlimited", "1048576", "1048576"), + ("1048576", "unlimited", "1048576", "1048576"), + ("8192", "16384", "8192", "16384"), + ("8192", "2097152", "8192", "2097152"), + ], +) +def test_scanner_limits_are_child_only( + scan_entrypoint: list[str], + tmp_path: Path, + soft: str, + hard: str, + scanner_soft: str, + scanner_hard: str, +) -> None: + result, events = _observe(tmp_path, scan_entrypoint, soft, hard) + assert result.returncode == 0, result.stdout + result.stderr + assert events == [ + ["before", soft, hard], + ["uvx", scanner_soft, scanner_hard], + ["gitleaks", soft, hard], + ["after", soft, hard], + ] + + +@pytest.mark.parametrize("surface", SURFACES) +@pytest.mark.parametrize("scanner_exit", [1, 2, 17]) +def test_scanner_failure_stops_later_stage( + scan_entrypoint: list[str], tmp_path: Path, scanner_exit: int +) -> None: + result, events = _observe( + tmp_path, scan_entrypoint, "8192", "unlimited", scanner_exit=scanner_exit + ) + assert result.returncode == scanner_exit, result.stdout + result.stderr + assert events == [ + ["before", "8192", "unlimited"], + ["uvx", "8192", "1048576"], + ["after", "8192", "unlimited"], + ] + + +@pytest.mark.parametrize("surface", SURFACES) +@pytest.mark.parametrize("fault", ["-H -s", "-S -s", "-S -s 1048576", "-H -s 1048576"]) +def test_limit_failure_never_starts_scanner( + scan_entrypoint: list[str], tmp_path: Path, fault: str +) -> None: + result, events = _observe(tmp_path, scan_entrypoint, "unlimited", "unlimited", fault=fault) + assert result.returncode == 73, result.stdout + result.stderr + assert events == [ + ["before", "unlimited", "unlimited"], + ["after", "unlimited", "unlimited"], + ] + + +@pytest.mark.parametrize("surface", SURFACES) +def test_gitleaks_failure_still_propagates(scan_entrypoint: list[str], tmp_path: Path) -> None: + result, events = _observe(tmp_path, scan_entrypoint, "8192", "unlimited", gitleaks_exit=19) + assert result.returncode == 19, result.stdout + result.stderr + assert events[-2:] == [ + ["gitleaks", "8192", "unlimited"], + ["after", "8192", "unlimited"], + ] + + +@pytest.mark.parametrize("surface", SURFACES) +@pytest.mark.parametrize( + ("soft_bytes", "expected_soft"), + [(1073741823, 1073741823), (1073741824, 1073741824), (1073741825, 1073741824)], +) +def test_soft_limit_preservation_uses_the_actual_byte_boundary( + scan_entrypoint: list[str], tmp_path: Path, soft_bytes: int, expected_soft: int +) -> None: + if resource.getrlimit(resource.RLIMIT_STACK)[1] != resource.RLIM_INFINITY: + pytest.skip("This case requires an inherited unlimited hard stack") + tools = tmp_path / "byte-recorders" + tools.mkdir() + _ = (tools / "uvx").write_text( + '#!/usr/bin/env bash\nexec "$PROBE_PYTHON" "$PROBE_SOURCE" observe\n' + ) + _ = (tools / "gitleaks").write_text("#!/usr/bin/env bash\nexit 0\n") + for name in ("uvx", "gitleaks"): + (tools / name).chmod(0o755) + events = tmp_path / "byte-events" + helper = FIXTURES / "scanner_bytes.py" + result = subprocess.run( + [sys.executable, str(helper), *scan_entrypoint], + env=dict(os.environ) + | { + "PATH": f"{tools}{os.pathsep}{os.environ['PATH']}", + "PROBE_PYTHON": sys.executable, + "PROBE_SOURCE": str(helper), + "SOFT_BYTES": str(soft_bytes), + "BYTE_EVENTS": str(events), + }, + capture_output=True, + text=True, + check=False, + timeout=35, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert json.loads(events.read_text()) == [expected_soft, 1073741824] + + +def test_scan_distinguishes_clean_input_from_blocking_finding( + render: RenderFn, tmp_path: Path +) -> None: + """An engine startup error must satisfy neither the clean nor violation control.""" + project = render({**MINIMAL, "enable_scanners": True}, tmp_path / "out") + tools = tmp_path / "tools" + tools.mkdir() + # Observe the later stage without requiring gitleaks in the generation harness. + gitleaks = tools / "gitleaks" + _ = gitleaks.write_text('#!/usr/bin/env bash\nprintf "gitleaks-reached\\n"\n') + gitleaks.chmod(0o755) + env = {"PATH": f"{tools}{os.pathsep}{os.environ['PATH']}"} + clean = run_in(project, "just", "scan", check=False, env=env) + assert clean.returncode == 0, clean.stdout + clean.stderr + assert "gitleaks-reached" in clean.stdout.splitlines() + + _ = (project / "src" / "demo_project" / "danger.py").write_text( + "def run(expr: str) -> object:\n return eval(expr)\n" + ) + blocked = run_in(project, "just", "scan", check=False, env=env) + assert blocked.returncode == 1, blocked.stdout + blocked.stderr + assert "no-eval" in blocked.stdout, blocked.stdout + blocked.stderr + assert "gitleaks-reached" not in blocked.stdout.splitlines() From 00b32b2d861e950aac73592b58f4b4a7a8d020a0 Mon Sep 17 00:00:00 2001 From: Ashlen Date: Fri, 23 Oct 2026 21:06:34 -0600 Subject: [PATCH 4/7] build: refresh audited development dependencies Update filelock to 4.2.1 and virtualenv to 21.14.10 in both development locks after new audit findings. Keep every other package, runtime declaration and verification threshold unchanged. --- automation/uv.lock | 12 ++++++------ uv.lock | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/automation/uv.lock b/automation/uv.lock index 46cd5d6..44c65f4 100644 --- a/automation/uv.lock +++ b/automation/uv.lock @@ -429,11 +429,11 @@ wheels = [ [[package]] name = "filelock" -version = "4.2.0" +version = "4.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f4/a9/1af41b37c3279712b22cdc63aac78a52432202b6fe1f9666a2a3d2831fb4/filelock-4.2.0.tar.gz", hash = "sha256:7a60906c75227cf04d0c273afadc8219400f11aeb13cc69591d4f6cdc6c8036e", size = 569667, upload-time = "2026-10-14T20:57:13.11Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/98/771d8ca889f220d7d7e352addf500666368f0fd081c6080a83767580c6e4/filelock-4.2.1.tar.gz", hash = "sha256:c4c937ccac2a813cee448e662a56366cfb374c0f8702e75f295c412cc1c5d5a3", size = 650997, upload-time = "2026-10-17T16:48:14.435Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/a3/9bc26acff301fe1aaea1cc3d82a1d57e0a34df3e1cadbfa91ac2dbcdde5c/filelock-4.2.0-py3-none-any.whl", hash = "sha256:2ff5690882e8cdb00ef31fb3d01a3094c29f30985426c59495afb1733f3b7238", size = 135032, upload-time = "2026-10-14T20:57:11.349Z" }, + { url = "https://files.pythonhosted.org/packages/28/00/db89d339bc4ecdc8087fa7b8607ba22ffc27bf96286f340c9620bea428b3/filelock-4.2.1-py3-none-any.whl", hash = "sha256:7be2662e9e826b9bba28afc0f843a123280d799a2ea44bb5d35ccec67905ae44", size = 162455, upload-time = "2026-10-17T16:48:12.937Z" }, ] [[package]] @@ -1762,7 +1762,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.14.9" +version = "21.14.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -1771,9 +1771,9 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/04/2f69ba1207ce59630ecea8ebfdaf5c8f9a14726f2408ccd145a18d1ef619/virtualenv-21.14.9.tar.gz", hash = "sha256:4d1bee354cd81c820da3bee56e38be3426490f8d1fc54e10ea5b2072ebad8ef8", size = 5500672, upload-time = "2026-10-17T16:09:42.345Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/ce/7136eeccc4ff32c06c3b523745af49c5cf6b2e95635d88ae8519a6f4d25b/virtualenv-21.14.10.tar.gz", hash = "sha256:f733501a49404c91b5bb1d34d107073b8f4b93a9a1ddb7e9a950eb8ce60f38ad", size = 5501185, upload-time = "2026-10-17T17:31:55.115Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/e5/9a3918219745e8bdd64fe0fd9a2d7ce4fffde9268fbc791634b7d4779de9/virtualenv-21.14.9-py3-none-any.whl", hash = "sha256:ac51744da66ae56ff814b087d65096c94de698ec520adc624b108b679655809b", size = 5494452, upload-time = "2026-10-17T16:09:40.584Z" }, + { url = "https://files.pythonhosted.org/packages/1f/83/6fb5c79de3fe1f174c5c7f0f7bb79e3fd7a464b14871f7abfdb66dd10396/virtualenv-21.14.10-py3-none-any.whl", hash = "sha256:bf4a0f9671731d097e017e41280aa05e1e1adcd536b0e804fceb3179947ad50e", size = 5494519, upload-time = "2026-10-17T17:31:53.082Z" }, ] [[package]] diff --git a/uv.lock b/uv.lock index c1317e5..f77184b 100644 --- a/uv.lock +++ b/uv.lock @@ -88,11 +88,11 @@ wheels = [ [[package]] name = "filelock" -version = "4.1.1" +version = "4.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/c8/1d457d9150ff948f2ce6ada7715e0eeebbe5d3b58a45271a1e222474bcd3/filelock-4.1.1.tar.gz", hash = "sha256:7ba0927482c5a814b0a7f391d029ccdb8010f576f0a74c0dcde1811e8bc4c1b6", size = 563430, upload-time = "2026-10-11T16:11:54.373Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/98/771d8ca889f220d7d7e352addf500666368f0fd081c6080a83767580c6e4/filelock-4.2.1.tar.gz", hash = "sha256:c4c937ccac2a813cee448e662a56366cfb374c0f8702e75f295c412cc1c5d5a3", size = 650997, upload-time = "2026-10-17T16:48:14.435Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8b/f837f52905395ba4510fe61f753c24833fb0a9c76e21267bb9f828b664a9/filelock-4.1.1-py3-none-any.whl", hash = "sha256:3f4a557945a7b0f95efeb1f432267affe5d45ac8ddde2aed1b97ebb62382c089", size = 132460, upload-time = "2026-10-11T16:11:52.753Z" }, + { url = "https://files.pythonhosted.org/packages/28/00/db89d339bc4ecdc8087fa7b8607ba22ffc27bf96286f340c9620bea428b3/filelock-4.2.1-py3-none-any.whl", hash = "sha256:7be2662e9e826b9bba28afc0f843a123280d799a2ea44bb5d35ccec67905ae44", size = 162455, upload-time = "2026-10-17T16:48:12.937Z" }, ] [[package]] @@ -705,7 +705,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.14.9" +version = "21.14.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -714,9 +714,9 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/04/2f69ba1207ce59630ecea8ebfdaf5c8f9a14726f2408ccd145a18d1ef619/virtualenv-21.14.9.tar.gz", hash = "sha256:4d1bee354cd81c820da3bee56e38be3426490f8d1fc54e10ea5b2072ebad8ef8", size = 5500672, upload-time = "2026-10-17T16:09:42.345Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/ce/7136eeccc4ff32c06c3b523745af49c5cf6b2e95635d88ae8519a6f4d25b/virtualenv-21.14.10.tar.gz", hash = "sha256:f733501a49404c91b5bb1d34d107073b8f4b93a9a1ddb7e9a950eb8ce60f38ad", size = 5501185, upload-time = "2026-10-17T17:31:55.115Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/e5/9a3918219745e8bdd64fe0fd9a2d7ce4fffde9268fbc791634b7d4779de9/virtualenv-21.14.9-py3-none-any.whl", hash = "sha256:ac51744da66ae56ff814b087d65096c94de698ec520adc624b108b679655809b", size = 5494452, upload-time = "2026-10-17T16:09:40.584Z" }, + { url = "https://files.pythonhosted.org/packages/1f/83/6fb5c79de3fe1f174c5c7f0f7bb79e3fd7a464b14871f7abfdb66dd10396/virtualenv-21.14.10-py3-none-any.whl", hash = "sha256:bf4a0f9671731d097e017e41280aa05e1e1adcd536b0e804fceb3179947ad50e", size = 5494519, upload-time = "2026-10-17T17:31:53.082Z" }, ] [[package]] From 59b239c850e6e8d09c51ce18364baf10c541f23b Mon Sep 17 00:00:00 2001 From: Ashlen Date: Fri, 23 Oct 2026 21:06:34 -0600 Subject: [PATCH 5/7] fix: preserve mapped assets and Copier provenance Include declared fixture files in inventories, receipts and reviewed return even when their names resemble generated reports or caches. Require concrete fingerprints before reporting a file as applied. Accept the documented Copier GitHub shorthand while retaining canonical cache and exact revision validation. Cover both paths through real workflow regressions and strengthen the existing receipt property. --- automation/CHANGELOG.md | 3 + automation/README.md | 10 +- .../src/kickstarter_workflow/environment.py | 4 +- automation/src/kickstarter_workflow/files.py | 25 +++-- .../src/kickstarter_workflow/git_context.py | 4 +- .../src/kickstarter_workflow/integrity.py | 4 +- automation/src/kickstarter_workflow/setup.py | 20 ++-- .../src/kickstarter_workflow/transfer.py | 39 ++++--- .../src/kickstarter_workflow/verification.py | 30 ++--- .../property/test_workflow_properties.py | 13 ++- .../tests/unit/test_documented_origin.py | 87 +++++++++++++++ automation/tests/unit/test_mapped_assets.py | 104 ++++++++++++++++++ 12 files changed, 287 insertions(+), 56 deletions(-) create mode 100644 automation/tests/unit/test_documented_origin.py create mode 100644 automation/tests/unit/test_mapped_assets.py diff --git a/automation/CHANGELOG.md b/automation/CHANGELOG.md index 8d5c9b3..1bc3ba4 100644 --- a/automation/CHANGELOG.md +++ b/automation/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Bind explicitly declared fixture assets despite report/cache discovery exclusions, and require concrete fingerprints for reviewed transfer and effect accounting. +- Reuse the documented `gh:maybebyte/python-kickstarter` Copier origin without relaxing canonical-cache or exact approved-revision validation. + - Exclude Git control files from product inventories so linked worktrees and separate-git-dir sources prepare without copying administrative pointers. - Keep mapped test paths literal through generated recipes and refuse unsupported parser syntax before tool execution. - Preserve mapped initializer bytes and prepare all-new source/test declarations without supplying their payloads. diff --git a/automation/README.md b/automation/README.md index b1813d6..52f8a5e 100644 --- a/automation/README.md +++ b/automation/README.md @@ -49,7 +49,10 @@ and outside scratch/cache expiry policies. It must be owned by the caller, mode All mappings are explicit **files**, not directory globs. Include runtime code, maintained tests, imported local dependencies, fixtures, assets and relevant context. -The full original tree is separately inventoried and copied for repository checks. +Explicit declarations remain inventoried even when their names resemble generated reports +or caches (for example, `fixtures/coverage.json`). Git/environment control paths, +links and special files remain forbidden. The full original tree is separately +inventoried and copied for repository checks. The scope review is a human-reviewed declaration, not proof of adequate coverage. An omitted dependency, meaningless assertion, or missing CI obligation remains a review finding even when mechanically selected checks pass. @@ -122,7 +125,10 @@ Modes: copy its scaffold back to the product. - **`existing`**: existing Kickstarter repository. Copy its complete relevant tree into an isolated task repository; validate and preserve its answers/configuration. - Use identical source/target mappings. No regeneration or upgrade occurs. + Use identical source/target mappings. No regeneration or upgrade occurs. The + documented `gh:maybebyte/python-kickstarter` origin is accepted alongside the + canonical HTTPS origin and the validated local cache path; the cache must still + be canonical and the recorded revision must resolve to the exact approved commit. - **`new`**: generate at the explicitly authorized, nonexistent `source` destination. Use identical source/target mappings. Set `isolate_git: true` only when explicitly authorizing a new independent Git root beneath an existing ancestor. Unchanged diff --git a/automation/src/kickstarter_workflow/environment.py b/automation/src/kickstarter_workflow/environment.py index ae6bfa1..78800a1 100644 --- a/automation/src/kickstarter_workflow/environment.py +++ b/automation/src/kickstarter_workflow/environment.py @@ -42,7 +42,9 @@ def _local_package(plan: Record, source: Path, metadata: Record, content: str) - raise WorkflowError("only the static default uv_build package layout is supported") _ = _strings(build.get("requires"), "build requirements") mapped = {text(item, "source"): text(item, "target") for item in mappings(plan)} - if any(mapped.get(name) != "product/" + name for name in inventory(source)): + if any( + mapped.get(name) != "product/" + name for name in inventory(source, plan, destination=True) + ): raise WorkflowError( "packaged environment requires complete layout-preserving source/assets mapping" ) diff --git a/automation/src/kickstarter_workflow/files.py b/automation/src/kickstarter_workflow/files.py index 7dc2d80..51d52c3 100644 --- a/automation/src/kickstarter_workflow/files.py +++ b/automation/src/kickstarter_workflow/files.py @@ -38,8 +38,8 @@ def confined(root: Path, relative: str) -> Path: return no_links(root / relative) -def inventory(root: Path) -> Record: - """Fingerprint all relevant regular files and modes; reject special files.""" +def inventory(root: Path, plan: Record | None = None, *, destination: bool = False) -> Record: + """Fingerprint regular files, including declared assets excluded from discovery.""" _ = no_links(root) result: Record = {} for directory, directories, files in os.walk( @@ -54,16 +54,23 @@ def inventory(root: Path) -> Record: if name == ".git" or name in REPORTS or name.endswith((".pyc", ".pyo")): continue path = current / name - metadata = path.stat() - if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: - raise WorkflowError("inventory requires independent regular files") - result[path.relative_to(root).as_posix()] = { - "sha256": digest(path.read_bytes()), - "mode": stat.S_IMODE(metadata.st_mode), - } + result[path.relative_to(root).as_posix()] = _fingerprint(path) + if plan is not None: + for item in mappings(plan): + name = text(item, "source" if destination else "target") + path = confined(root, name) + if path.exists(): + result[name] = _fingerprint(path) return result +def _fingerprint(path: Path) -> Record: + metadata = path.stat() + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise WorkflowError("inventory requires independent regular files") + return {"sha256": digest(path.read_bytes()), "mode": stat.S_IMODE(metadata.st_mode)} + + def _raise_scan_error(error: OSError) -> None: raise error diff --git a/automation/src/kickstarter_workflow/git_context.py b/automation/src/kickstarter_workflow/git_context.py index 05900fe..2bc1f86 100644 --- a/automation/src/kickstarter_workflow/git_context.py +++ b/automation/src/kickstarter_workflow/git_context.py @@ -2,7 +2,6 @@ from pathlib import Path -from kickstarter_workflow.files import inventory from kickstarter_workflow.process import Runner from kickstarter_workflow.records import ( Record, @@ -118,9 +117,8 @@ def restore(task: Path, root: Path, runner: Runner) -> None: raise WorkflowError("repository Git context was not faithfully restored") -def stage_candidate(root: Path, runner: Runner) -> None: +def stage_candidate(root: Path, runner: Runner, files: Record) -> None: """Enroll exact candidate bytes without filters, ignores or source-index writes.""" - files = inventory(root) if not files: raise WorkflowError("empty repository candidate") _ = runner.run(["git", "read-tree", "--empty"], root) diff --git a/automation/src/kickstarter_workflow/integrity.py b/automation/src/kickstarter_workflow/integrity.py index 92e5e21..8634329 100644 --- a/automation/src/kickstarter_workflow/integrity.py +++ b/automation/src/kickstarter_workflow/integrity.py @@ -41,7 +41,7 @@ def validate_preparation(state: Record, task: Path, runner: Runner) -> None: if not (task / "baseline").is_dir(): raise WorkflowError("retained baseline is missing") private_directory(task / "baseline") - if inventory(task / "baseline") != load(task / "source.json"): + if inventory(task / "baseline", plan, destination=True) != load(task / "source.json"): raise WorkflowError("retained baseline is missing or changed") git_root(runner, workspace) validate(state, task, runner) @@ -50,7 +50,7 @@ def validate_preparation(state: Record, task: Path, runner: Runner) -> None: _, revision = template(plan, runner) if revision != state.get("template_revision") or runner.identities(task) != state.get("tools"): raise WorkflowError("stale setup provenance") - baseline, current = load(task / "scaffold.json"), inventory(workspace) + baseline, current = load(task / "scaffold.json"), inventory(workspace, plan) protected = { name for name in baseline diff --git a/automation/src/kickstarter_workflow/setup.py b/automation/src/kickstarter_workflow/setup.py index 78cc424..4076b2b 100644 --- a/automation/src/kickstarter_workflow/setup.py +++ b/automation/src/kickstarter_workflow/setup.py @@ -129,16 +129,22 @@ def _generate(plan: Record, workspace: Path, cache: Path, revision: str, runner: git_root(runner, workspace) -def _reuse(source: Path, workspace: Path, cache: Path, revision: str, runner: Runner) -> None: +def _reuse( + source: Path, workspace: Path, cache: Path, revision: str, runner: Runner, files: Record +) -> None: answers = (source / ".copier-answers.yml").read_text() origin = re.search(r"^_src_path: (.+)$", answers, re.MULTILINE) commit = re.search(r"^_commit: ([a-zA-Z0-9.\-]+)$", answers, re.MULTILINE) - if origin is None or origin[1] not in {CANONICAL, str(cache)} or commit is None: + if ( + origin is None + or origin[1] not in {CANONICAL, "gh:maybebyte/python-kickstarter", str(cache)} + or commit is None + ): raise WorkflowError("existing project lacks canonical Copier provenance") if runner.run(["git", "rev-parse", "--verify", commit[1] + "^{commit}"], cache) != revision: raise WorkflowError("existing project revision differs; upgrades are separate") workspace.mkdir(mode=0o700) - copy_inventory(source, workspace, inventory(source)) + copy_inventory(source, workspace, files) _ = runner.run( ["git", "-c", "init.templateDir=", "init", "--quiet", str(workspace)], workspace.parent ) @@ -200,12 +206,12 @@ def prepare(plan: Record, task: Path, runner: Runner) -> Record: before: Record = {} else: git_root(runner, source) - before = inventory(source) + before = inventory(source, plan, destination=True) snapshot = task / "baseline" snapshot.mkdir(mode=0o700) copy_inventory(source, snapshot, before) if mode == "existing": - _reuse(source, workspace, cache, revision, runner) + _reuse(source, workspace, cache, revision, runner, before) else: if (source / ".copier-answers.yml").exists(): raise WorkflowError("generated project must use existing mode") @@ -213,7 +219,7 @@ def prepare(plan: Record, task: Path, runner: Runner) -> Record: copy_mapping(plan, source, workspace) _adapt_workbench(plan, workspace) adapt_environment(plan, source, workspace) - if inventory(source) != before: + if inventory(source, plan, destination=True) != before: raise WorkflowError("source changed during preparation") gate_policy(workspace, existing=mode == "existing") git_sha256 = retain(source, task, runner) @@ -224,7 +230,7 @@ def prepare(plan: Record, task: Path, runner: Runner) -> Record: workspace, ) save(task / "source.json", before) - save(task / "scaffold.json", inventory(workspace)) + save(task / "scaffold.json", inventory(workspace, plan)) return { "status": "prepared", "plan": plan, diff --git a/automation/src/kickstarter_workflow/transfer.py b/automation/src/kickstarter_workflow/transfer.py index 16664f8..ef2a19c 100644 --- a/automation/src/kickstarter_workflow/transfer.py +++ b/automation/src/kickstarter_workflow/transfer.py @@ -83,12 +83,12 @@ def _reconcile(state: Record, task: Path, runner: Runner) -> tuple[Record, bool] plan = object_value(state, "plan") source = Path(text(plan, "source")) before = load(task / "source.json") - current = inventory(source) + current = inventory(source, plan, destination=True) if current == before: return state, False merged: list[tuple[Path, bytes, int]] = [] workspace = Path(text(state, "workspace")) - candidate_inventory = inventory(workspace) + candidate_inventory = inventory(workspace, plan) for item in mappings(plan): name, target = text(item, "source"), text(item, "target") if current.get(name) == before.get(name): @@ -110,7 +110,7 @@ def _reconcile(state: Record, task: Path, runner: Runner) -> tuple[Record, bool] save(task / "source.json", current) state = {**state, "source_sha256": digest((task / "source.json").read_bytes())} updated = verify(state, task, runner.child()) - if inventory(workspace) != candidate_inventory: + if inventory(workspace, plan) != candidate_inventory: report = task / ("reconciliation-" + text(updated, "receipt") + ".diff") _ = report.write_text(diff) updated.update( @@ -146,7 +146,7 @@ def _journal( ) -> Record: plan = object_value(state, "plan") source, workspace = Path(text(plan, "source")), Path(text(state, "workspace")) - current, candidate = inventory(source), inventory(workspace) + current, candidate = inventory(source, plan, destination=True), inventory(workspace, plan) entries: Record = {} for item in mappings(plan): name, target = text(item, "source"), text(item, "target") @@ -155,7 +155,7 @@ def _journal( if name in allowlist: entries[name] = { "before": current.get(name), - "after": candidate.get(target), + "after": object_value(candidate, target), "target": target, } journal: Record = { @@ -191,29 +191,33 @@ def _replace(candidate: Path, destination: Path) -> None: def effects(journal: Record) -> list[str]: """Report actual applied paths even after interruption before a journal update.""" state = object_value(journal, "state") - source = Path(text(object_value(state, "plan"), "source")) - current = inventory(source) + plan = object_value(state, "plan") + source = Path(text(plan, "source")) + current = inventory(source, plan, destination=True) return [ name for name, entry in object_value(journal, "entries").items() - if isinstance(entry, dict) and current.get(name) == entry.get("after") + if isinstance(entry, dict) + and isinstance(entry.get("after"), dict) + and current.get(name) == entry["after"] ] def _apply(journal: Record, task: Path) -> None: state = object_value(journal, "state") - source = Path(text(object_value(state, "plan"), "source")) + plan = object_value(state, "plan") + source = Path(text(plan, "source")) workspace = Path(text(state, "workspace")) entries = object_value(journal, "entries") - current = inventory(source) + current = inventory(source, plan, destination=True) for name in entries: entry = object_value(entries, name) if current.get(name) not in (entry.get("before"), entry.get("after")): raise WorkflowError("destination changed during interrupted return; decision required") for name in entries: entry = object_value(entries, name) - fresh = inventory(source).get(name) - if fresh == entry.get("after"): + fresh = inventory(source, plan, destination=True).get(name) + if fresh == object_value(entry, "after"): continue if fresh != entry.get("before"): raise WorkflowError("destination changed immediately before transfer") @@ -266,7 +270,7 @@ def _validate_destination(journal: Record, task: Path, runner: Runner) -> Record state = object_value(journal, "state") plan = object_value(state, "plan") source, workspace = Path(text(plan, "source")), Path(text(state, "workspace")) - before = inventory(source) + before = inventory(source, plan, destination=True) transferred = effects(journal) expected = dict(object_value(journal, "destination_before")) entries = object_value(journal, "entries") @@ -278,10 +282,10 @@ def _validate_destination(journal: Record, task: Path, runner: Runner) -> Record _require_inventory(before, expected) repository_checks(plan, source, workspace, runner) measured = product_evidence(plan, source, workspace, runner, destination=True) - _require_inventory(inventory(source), before) + _require_inventory(inventory(source, plan, destination=True), before) _ = validate_receipt(state, task, runner) except (WorkflowError, OSError, ValueError) as error: - current = inventory(source) + current = inventory(source, plan, destination=True) changes: list[Value] = [ name for name in sorted(before.keys() | current.keys()) @@ -306,7 +310,7 @@ def _validate_destination(journal: Record, task: Path, runner: Runner) -> Record { "status": "returned", "applied": list(effects(journal)), - "destination": inventory(source), + "destination": inventory(source, plan, destination=True), "destination_evidence": str(runner.evidence), } ) @@ -325,8 +329,9 @@ def cleanup(state: Record, task: Path) -> Record: """Remove resolved task workspace only; retain records and raw diagnostics.""" if state["status"] not in {"returned", "exempt", "cleaned"}: raise WorkflowError("unresolved work must be preserved") + plan = object_value(state, "plan") workspace = task / "workspace" if workspace.exists(): - _ = inventory(workspace) + _ = inventory(workspace, plan) shutil.rmtree(workspace) return {**state, "status": "cleaned"} diff --git a/automation/src/kickstarter_workflow/verification.py b/automation/src/kickstarter_workflow/verification.py index 3bfad6d..183a7ee 100644 --- a/automation/src/kickstarter_workflow/verification.py +++ b/automation/src/kickstarter_workflow/verification.py @@ -33,9 +33,10 @@ def binding(state: Record, runner: Runner) -> Record: """Bind all candidate bytes/modes, immutable plan, and actual tool identities.""" workspace = Path(text(state, "workspace")) + plan = object_value(state, "plan") return { "interpreter": environment_identity(workspace), - "candidate": inventory(workspace), + "candidate": inventory(workspace, plan), "plan": object_value(state, "plan"), "tools": runner.identities(workspace), "git": identity(workspace, runner), @@ -72,7 +73,7 @@ def _repository(plan: Record, task: Path, workspace: Path, runner: Runner) -> Pa target.chmod(candidate.stat().st_mode & 0o777) _ = runner.run(["git", "-c", "init.templateDir=", "init", "--quiet", str(root)], task) restore(task, root, runner) - stage_candidate(root, runner) + stage_candidate(root, runner, inventory(root, plan, destination=True)) return root @@ -95,11 +96,14 @@ def repository_checks(plan: Record, root: Path, workspace: Path, runner: Runner) raise WorkflowError("invalid repository check policy") commands.extend(string_list({"argv": check}, "argv") for check in required) runner.env["PYTHONPATH"] = os_path(root, names(plan, "source", destination=True)) - before: Record = {"files": inventory(root), "git": identity(root, runner)} + before: Record = { + "files": inventory(root, plan, destination=True), + "git": identity(root, runner), + } save(runner.evidence / ("repository-inputs-" + uuid.uuid4().hex + ".json"), before) for command in commands: _ = runner.run(command, root) - if {"files": inventory(root), "git": identity(root, runner)} != before: + if {"files": inventory(root, plan, destination=True), "git": identity(root, runner)} != before: raise WorkflowError("repository inputs changed while checks were running") @@ -172,16 +176,16 @@ def verify(state: Record, task: Path, runner: Runner) -> Record: """Run all mandatory gates and publish a candidate-bound receipt only on success.""" plan = object_value(state, "plan") workspace = Path(text(state, "workspace")) - if plan["mode"] != "new" and inventory(Path(text(plan, "source"))) != load( - task / "source.json" - ): + if plan["mode"] != "new" and inventory( + Path(text(plan, "source")), plan, destination=True + ) != load(task / "source.json"): raise WorkflowError( "source changed since preparation; return reconciliation or a decision is required" ) git_root(runner, workspace) gate_policy(workspace, existing=plan["mode"] == "existing") baseline = load(task / "scaffold.json") - current = inventory(workspace) + current = inventory(workspace, plan) protected = { name for name in baseline @@ -197,21 +201,21 @@ def verify(state: Record, task: Path, runner: Runner) -> Record: raise WorkflowError("empty or missing product target") if plan["mode"] == "new": scaffold = load(task / "scaffold.json") - candidate = inventory(workspace) + candidate = inventory(workspace, plan) if all(scaffold.get(name) == candidate.get(name) for name in names(plan, "test")): raise WorkflowError("scaffold-only tests are not product evidence") runner.env["PYTHONPATH"] = os_path(workspace, names(plan, "source")) _ = runner.run(["uv", "sync", "--locked"], workspace) - stage_candidate(workspace, runner) + stage_candidate(workspace, runner, inventory(workspace, plan)) before = binding(state, runner) _ = runner.run(["just", "ci"], workspace) product = product_evidence(plan, workspace, workspace, runner) repository_checks(plan, _repository(plan, task, workspace, runner), workspace, runner) if binding(state, runner) != before: raise WorkflowError("candidate changed while checks were running") - if plan["mode"] != "new" and inventory(Path(text(plan, "source"))) != load( - task / "source.json" - ): + if plan["mode"] != "new" and inventory( + Path(text(plan, "source")), plan, destination=True + ) != load(task / "source.json"): raise WorkflowError("source changed while checks were running") receipt: Record = {"binding": before, **product, "status": "passed"} receipt["artifacts"] = { diff --git a/automation/tests/property/test_workflow_properties.py b/automation/tests/property/test_workflow_properties.py index 394a374..655a363 100644 --- a/automation/tests/property/test_workflow_properties.py +++ b/automation/tests/property/test_workflow_properties.py @@ -10,6 +10,7 @@ from kickstarter_workflow.records import Record, load, save, text from kickstarter_workflow.workflow import Workflow +from tests.unit.test_mapped_assets import ASSETS, asset_plan pytestmark = pytest.mark.property @@ -75,6 +76,7 @@ def check(prefix: str, name: str) -> None: def test_arbitrary_candidate_config_test_and_mode_changes_invalidate_receipts( tmp_path: Path, plan: Record ) -> None: + plan = asset_plan(plan) workflow = Workflow(tmp_path / "state") prepared = workflow.dispatch("prepare", plan) assert prepared["status"] == "prepared", prepared @@ -84,11 +86,19 @@ def test_arbitrary_candidate_config_test_and_mode_changes_invalidate_receipts( @given( name=st.sampled_from( - ["product/app.py", "product/test_app.py", "pyproject.toml", "uv.lock", "justfile"] + [ + "product/app.py", + "product/test_app.py", + "pyproject.toml", + "uv.lock", + "justfile", + *("product/" + name for name in ASSETS), + ] ), suffix=st.binary(min_size=1, max_size=64), mode_change=st.booleans(), ) + @example(name="product/fixtures/coverage.json", suffix=b"changed", mode_change=False) def check(name: str, suffix: bytes, *, mode_change: bool) -> None: path = workspace / name original, mode = path.read_bytes(), path.stat().st_mode & 0o777 @@ -99,7 +109,6 @@ def check(name: str, suffix: bytes, *, mode_change: bool) -> None: _ = path.write_bytes(original + suffix) rejected = workflow.dispatch("status", plan) assert rejected["status"] == "blocked", rejected - assert "stale" in text(rejected, "reason") finally: _ = path.write_bytes(original) path.chmod(mode) diff --git a/automation/tests/unit/test_documented_origin.py b/automation/tests/unit/test_documented_origin.py new file mode 100644 index 0000000..268710a --- /dev/null +++ b/automation/tests/unit/test_documented_origin.py @@ -0,0 +1,87 @@ +"""Reuse real Copier output from the documented GitHub abbreviation, offline.""" + +import os +import re +from pathlib import Path + +import pytest + +from kickstarter_workflow.records import Record, object_value, text +from kickstarter_workflow.workflow import Workflow +from tests.conftest import CANONICAL, PIN, command + + +def test_documented_origin_is_reused_without_broadening_provenance( + tmp_path: Path, plan: Record, template_cache: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "generated" + answers = object_value(plan, "answers") + arguments = [ + "copier", + "copy", + "--defaults", + "--skip-tasks", + "--vcs-ref", + PIN, + "--data", + "enable_precommit_install=false", + ] + for key in answers: + arguments.extend(["--data", key + "=" + text(answers, key)]) + # Only Git transport is redirected; Copier receives the documented origin. + with monkeypatch.context() as transport: + count = int(os.environ.get("GIT_CONFIG_COUNT", "0")) + transport.setenv("GIT_CONFIG_COUNT", str(count + 1)) + transport.setenv(f"GIT_CONFIG_KEY_{count}", "url." + template_cache.as_uri() + ".insteadOf") + transport.setenv(f"GIT_CONFIG_VALUE_{count}", CANONICAL) + transport.setenv("GIT_ALLOW_PROTOCOL", "file") + command(*arguments, "gh:maybebyte/python-kickstarter", str(source), cwd=tmp_path) + command("git", "init", "--quiet", "--template=", str(source), cwd=tmp_path) + request: Record = { + **plan, + "mode": "existing", + "source": str(source), + "mapping": [ + { + "source": "src/sample/__init__.py", + "target": "src/sample/__init__.py", + "role": "source", + }, + { + "source": "tests/unit/test_example.py", + "target": "tests/unit/test_example.py", + "role": "test", + }, + ], + "repository_checks": [["just", "ci"]], + } + answer_file = source / ".copier-answers.yml" + original = answer_file.read_bytes() + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", request) + assert prepared["status"] == "prepared", prepared + workspace = Path(text(prepared, "workspace")) + assert prepared["template_revision"] == PIN + assert answer_file.read_bytes() == original + assert (workspace / ".copier-answers.yml").read_bytes() == original + for name in ("pyproject.toml", "justfile"): + assert (workspace / name).read_bytes() == (source / name).read_bytes() + assert workflow.dispatch("resume", request)["status"] == "prepared" + invalid = ( + ("_src_path", "gh:someone-else/python-kickstarter"), + ("_src_path", "gh:maybebyte/python-kickstarter.git"), + ("_commit", "v0.2.0"), + ) + for index, (key, value) in enumerate(invalid): + changed = re.sub( + r"^" + key + r": .+$", key + ": " + value, original.decode(), flags=re.MULTILINE + ) + assert changed.encode() != original + _ = answer_file.write_text(changed) + task = "invalid-" + str(index) + refused = workflow.dispatch("prepare", {**request, "task": task}) + assert refused["status"] == "blocked", refused + assert answer_file.read_text() == changed + assert not (tmp_path / "state/sample" / task / "workspace").exists() + _ = answer_file.write_bytes(original) + assert workflow.dispatch("resume", request)["status"] == "prepared" diff --git a/automation/tests/unit/test_mapped_assets.py b/automation/tests/unit/test_mapped_assets.py new file mode 100644 index 0000000..7dc17c9 --- /dev/null +++ b/automation/tests/unit/test_mapped_assets.py @@ -0,0 +1,104 @@ +"""Declared fixture bytes remain part of verification and reviewed return.""" + +from pathlib import Path + +import pytest + +from kickstarter_workflow.records import Record, text +from kickstarter_workflow.workflow import Workflow +from tests.unit.test_transfer import review + +ASSETS = ( + "fixtures/coverage.json", + "fixtures/coverage.xml", + "fixtures/.coverage", + "fixtures/requirements-audit.txt", + "fixtures/__pycache__/example.pyc", +) + + +def asset_plan(plan: Record) -> Record: + source = Path(text(plan, "source")) + mapping = plan["mapping"] + assert isinstance(mapping, list) + for name in ASSETS: + path = source / name + path.parent.mkdir(parents=True, exist_ok=True) + _ = path.write_bytes(b"original fixture\n") + return { + **plan, + "mapping": [ + *mapping, + *({"source": name, "target": "product/" + name, "role": "context"} for name in ASSETS), + ], + } + + +def test_declared_assets_require_allowlisting_and_are_actually_returned( + tmp_path: Path, plan: Record +) -> None: + request = asset_plan(plan) + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", request) + assert prepared["status"] == "prepared", prepared + workspace = Path(text(prepared, "workspace")) + source = Path(text(plan, "source")) + for name in ASSETS: + asset = workspace / "product" / name + assert asset.read_bytes() == b"original fixture\n" + _ = asset.write_bytes(b"reviewed fixture\n") + asset.chmod(0o640) + verified = workflow.dispatch("verify", request) + assert verified["status"] == "verified", verified + receipt = text(verified, "receipt") + omitted = workflow.dispatch("return", review(receipt, "app.py")) + assert omitted["status"] == "blocked", omitted + assert not (workspace.parent / "return.json").exists() + assert workflow.dispatch("status", request)["receipt"] == receipt + for name in ASSETS: + assert (source / name).read_bytes() == b"original fixture\n" + returned = workflow.dispatch("return", review(receipt, *ASSETS)) + assert returned["status"] == "returned", returned + assert returned["destination_validated"] is True + applied = returned["applied"] + assert isinstance(applied, list) + assert {str(item) for item in applied} == set(ASSETS) + for name in ASSETS: + assert (source / name).read_bytes() == b"reviewed fixture\n" + assert (source / name).stat().st_mode & 0o777 == 0o640 + + +@pytest.mark.parametrize("fault", ["git", "venv", "outside", "symlink", "hardlink"]) +def test_declared_assets_do_not_bypass_filesystem_safety( + tmp_path: Path, plan: Record, fault: str +) -> None: + source = Path(text(plan, "source")) + payload = tmp_path / "outside.txt" + _ = payload.write_bytes(b"untouched outside payload\n") + name = { + "git": ".git/fixture.json", + "venv": ".venv/fixture.json", + "outside": "../outside.txt", + "symlink": "fixtures/coverage.json", + "hardlink": "fixtures/coverage.json", + }[fault] + if fault in {"symlink", "hardlink"}: + asset = source / name + asset.parent.mkdir(parents=True) + if fault == "symlink": + asset.symlink_to(payload) + else: + asset.hardlink_to(payload) + mapping = plan["mapping"] + assert isinstance(mapping, list) + request: Record = { + **plan, + "mapping": [ + *mapping, + {"source": name, "target": "product/fixture.json", "role": "context"}, + ], + } + result = Workflow(tmp_path / "state").dispatch("prepare", request) + assert result["status"] == "blocked", result + assert not (tmp_path / "state/sample/one/workspace").exists() + assert payload.read_bytes() == b"untouched outside payload\n" From 0304b74dcaf0b36896259638f7ea95cabb7d544f Mon Sep 17 00:00:00 2001 From: Ashlen Date: Fri, 23 Oct 2026 21:06:34 -0600 Subject: [PATCH 6/7] test: verify gate behavior and structured outcomes Exercise required gate commands with their effective shell settings and controlled process failures. Accept equivalent constant flags without allowing skipped gates or masked failures. Check pending-state preservation and top-level CLI JSON rather than diagnostic vocabulary or separator whitespace. --- automation/tests/unit/test_cli.py | 4 +- automation/tests/unit/test_safety.py | 15 +- tests/policy/test_automation.py | 247 +++++++++++++++++++++++++-- 3 files changed, 249 insertions(+), 17 deletions(-) diff --git a/automation/tests/unit/test_cli.py b/automation/tests/unit/test_cli.py index 44b19c9..293796d 100644 --- a/automation/tests/unit/test_cli.py +++ b/automation/tests/unit/test_cli.py @@ -19,7 +19,7 @@ def test_cli_emits_structured_exemption_and_rejects_invalid_input( ) args = ["--state", str(tmp_path / "state"), "prepare", str(request)] assert main(args) == 0 - assert '"status": "exempt"' in capsys.readouterr().out + assert json.loads(capsys.readouterr().out)["status"] == "exempt" _ = request.write_text("not json") assert main(args) == 2 - assert '"status": "blocked"' in capsys.readouterr().out + assert json.loads(capsys.readouterr().out)["status"] == "blocked" diff --git a/automation/tests/unit/test_safety.py b/automation/tests/unit/test_safety.py index 63913e3..491c196 100644 --- a/automation/tests/unit/test_safety.py +++ b/automation/tests/unit/test_safety.py @@ -26,13 +26,18 @@ def test_pending_state_write_blocks_resume_instead_of_reusing_old_readiness(tmp_ "mode": "disposable", "reason": "diagnostic", } - assert workflow.dispatch("prepare", request)["status"] == "exempt" - pending = tmp_path / "state/sample/one/task.pending" - _ = pending.write_text('{"status": "preparing"}') + prepared = workflow.dispatch("prepare", request) + assert prepared["status"] == "exempt" + assert workflow.dispatch("resume", request)["status"] == "exempt" + task = tmp_path / "state/sample/one/task.json" + before = task.read_bytes() + pending = task.with_suffix(".pending") + unfinished = b'{"status": "preparing"}' + _ = pending.write_bytes(unfinished) result = workflow.dispatch("resume", request) assert result["status"] == "blocked", result - assert "pending" in text(result, "reason") - assert pending.exists() + assert pending.read_bytes() == unfinished + assert task.read_bytes() == before @pytest.mark.parametrize( diff --git a/tests/policy/test_automation.py b/tests/policy/test_automation.py index 2bf8dd5..0dd8cc3 100644 --- a/tests/policy/test_automation.py +++ b/tests/policy/test_automation.py @@ -1,34 +1,261 @@ """Keep the helper's generated gates on the maintainer delivery path.""" +import argparse +import json import re +import shlex +import shutil +import subprocess from pathlib import Path from typing import cast +import pytest import yaml ROOT = Path(__file__).resolve().parent.parent.parent +def _preflight(script: str) -> None: + # Confinement, not the policy oracle: only inert stand-ins and boolean shell + # composition may execute. No expansions, redirects, backgrounding or paths + # outside this fixture. Extend this small vocabulary only after review. + allowed = { + "uv", + "sync", + "--locked", + "--project", + "automation", + "just", + "--justfile", + "automation/justfile", + "ci", + "automation-ci", + "true", + "false", + "||", + "&&", + ";", + "--project=automation", + "--justfile=automation/justfile", + } + for line in script.splitlines(): + command = line.strip().lstrip("@-") + lexer = shlex.shlex(command, posix=True, punctuation_chars=";&|") + lexer.whitespace_split = True + tokens = list(lexer) + assert set(tokens) <= allowed, tokens + + +def _bash_arguments(shell: str) -> list[str]: + # GitHub's Linux default is `bash -e {0}`; explicit `bash` adds pipefail. + # Custom templates keep their own options. Admit only inert Bash options, + # never startup files, command strings, other interpreters or extra operands. + if shell == "bash": + shell = "bash --noprofile --norc -e -o pipefail {0}" + tokens = shlex.split(shell) + assert tokens + assert shell.split(maxsplit=1)[0] == "bash" + assert tokens[-1] == "{0}" + arguments = tokens[1:-1] + assert all( + argument in {"--noprofile", "--norc", "pipefail"} + or re.fullmatch(r"-[euo]+", argument) is not None + for argument in arguments + ) + return arguments + + +def _effective_shell( + workflow: dict[str, object], job: dict[str, object], step: dict[str, object] +) -> str: + shell: object = "bash -e {0}" + for scope in (workflow, job): + defaults = cast("dict[str, dict[str, object]]", scope.get("defaults", {})) + shell = defaults.get("run", {}).get("shell", shell) + shell = step.get("shell", shell) + assert isinstance(shell, str) + return shell + + +def _constant_boolean(condition: object, *, expected: bool) -> bool: + # Admit equivalent constant flags, not a general expression interpreter. + if condition is expected: + return True + if not isinstance(condition, str): + return False + expression = condition.strip() + if expression.startswith("${{") and expression.endswith("}}"): + expression = expression[3:-2].strip() + return expression == ("true" if expected else "false") + + +def _probe( + tmp_path: Path, + script: str, + *, + recipe: bool, + sync_exit: int, + gate_exit: int, + shell: str = "bash -e {0}", +) -> None: + _preflight(script) + arguments = _bash_arguments(shell) + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + # These external-command stand-ins record the real argv, not source spelling. + # They cannot install dependencies or run the expensive helper gate. + for name, status in (("uv", sync_exit), ("just", gate_exit)): + executable = bin_dir / name + _ = executable.write_text( + "\n".join( + [ + "#!/bin/sh", + f'printf "%s\\n" "$@" >> "$PROBE_ROOT/{name}.log"', + f'printf "\\n" >> "$PROBE_ROOT/{name}.log"', + f"exit {status}", + "", + ] + ) + ) + executable.chmod(0o700) + bash = shutil.which("bash") + just = shutil.which("just") + assert bash is not None + assert just is not None + (bin_dir / "bash").symlink_to(bash) + env = { + "PATH": str(bin_dir), + "HOME": str(tmp_path), + "TMPDIR": str(tmp_path), + "PROBE_ROOT": str(tmp_path), + } + if recipe: + parsed = cast( + "dict[str, dict[str, dict[str, object]]]", + json.loads( + subprocess.check_output( + [just, "--dump", "--dump-format", "json"], + cwd=ROOT, + env=env, + text=True, + timeout=10, + ) + ), + ) + recipe_shell = parsed["settings"]["shell"] + assert recipe_shell["command"] == "bash" + shell_args = cast("list[str]", recipe_shell["arguments"]) + assert set(shell_args) <= {"-e", "-u", "-eu", "-ue", "-c", "-o", "pipefail"} + setting = "set shell := " + json.dumps(["bash", *shell_args]) + _ = (tmp_path / "justfile").write_text(setting + "\nautomation-ci:\n" + script) + command = [just, "automation-ci"] + else: + script_path = tmp_path / "gate.sh" + _ = script_path.write_text(script) + command = [bash, *arguments, str(script_path)] + result = subprocess.run( + command, cwd=tmp_path, env=env, capture_output=True, text=True, timeout=10, check=False + ) + assert result.returncode == (sync_exit or gate_exit), result + if recipe: + sync = argparse.ArgumentParser() + _ = sync.add_argument("operation", choices=["sync"]) + _ = sync.add_argument("--project") + _ = sync.add_argument("--locked", action="store_true") + calls = (tmp_path / "uv.log").read_text().strip().split("\n\n") + assert calls + for call in calls: + options = cast("dict[str, object]", vars(sync.parse_args(call.splitlines()))) + assert options["project"] == "automation" + assert options["locked"] is True + gate_log = tmp_path / "just.log" + if sync_exit: + assert not gate_log.exists() + else: + gate = argparse.ArgumentParser() + _ = gate.add_argument("--justfile") + _ = gate.add_argument("target") + calls = gate_log.read_text().strip().split("\n\n") + assert calls + for call in calls: + options = cast("dict[str, object]", vars(gate.parse_args(call.splitlines()))) + assert options["justfile"] == ("automation/justfile" if recipe else None) + assert options["target"] == ("ci" if recipe else "automation-ci") + + def test_automation_gate_is_required_locally_and_in_ci() -> None: justfile = (ROOT / "justfile").read_text() ci = re.search(r"^ci: (.+)$", justfile, re.MULTILINE) assert ci is not None assert "automation-ci" in ci.group(1).split() - recipe = re.search(r"^automation-ci:\n((?: .+\n)+)", justfile, re.MULTILINE) - assert recipe is not None - assert "uv sync --locked --project automation" in recipe.group(1) - assert "just --justfile automation/justfile ci" in recipe.group(1) workflow = cast( "dict[str, object]", yaml.safe_load((ROOT / ".github" / "workflows" / "test-template.yml").read_text()), ) jobs = cast("dict[str, object]", workflow["jobs"]) - assert "automation" in jobs job = cast("dict[str, object]", jobs["automation"]) - assert "continue-on-error" not in job + assert _constant_boolean(job.get("continue-on-error", False), expected=False) + assert _constant_boolean(job.get("if", True), expected=True) steps = cast("list[dict[str, object]]", job["steps"]) - assert any( - step.get("run") == "just automation-ci" - and step.get("env") == {"TMPDIR": "${{ runner.temp }}", "HYPOTHESIS_PROFILE": "ci"} - for step in steps + gate_steps = [step for step in steps if "automation-ci" in str(step.get("run", ""))] + assert gate_steps + for step in gate_steps: + assert _constant_boolean(step.get("continue-on-error", False), expected=False) + assert _constant_boolean(step.get("if", True), expected=True) + env = cast("dict[str, object]", step["env"]) + assert env["TMPDIR"] == "${{ runner.temp }}" + assert env["HYPOTHESIS_PROFILE"] == "ci" + + +@pytest.mark.parametrize(("sync_exit", "gate_exit"), [(0, 0), (19, 0), (0, 17)]) +def test_local_gate_propagates_failures(tmp_path: Path, sync_exit: int, gate_exit: int) -> None: + justfile = (ROOT / "justfile").read_text() + recipe = re.search(r"^automation-ci:\n((?:[ \t]+[^\n]*\n)+)", justfile, re.MULTILINE) + assert recipe is not None + _probe(tmp_path, recipe.group(1), recipe=True, sync_exit=sync_exit, gate_exit=gate_exit) + + +@pytest.mark.parametrize("gate_exit", [0, 17]) +def test_ci_gate_propagates_failures(tmp_path: Path, gate_exit: int) -> None: + workflow = cast( + "dict[str, object]", + yaml.safe_load((ROOT / ".github" / "workflows" / "test-template.yml").read_text()), ) + jobs = cast("dict[str, dict[str, object]]", workflow["jobs"]) + job = jobs["automation"] + steps = cast("list[dict[str, object]]", job["steps"]) + gate_steps = [step for step in steps if "automation-ci" in str(step.get("run", ""))] + assert gate_steps + for index, step in enumerate(gate_steps): + shell = _effective_shell(workflow, job, step) + directory = tmp_path / str(index) + directory.mkdir() + _probe( + directory, + str(step["run"]), + recipe=False, + sync_exit=0, + gate_exit=gate_exit, + shell=shell, + ) + + +@pytest.mark.parametrize( + ("script", "shell"), + [ + ("uv sync --project ../outside --locked", "bash"), + ("just automation-ci > outside", "bash"), + ("just $(true)", "bash"), + ("just automation-ci &", "bash"), + ("just automation-ci", "sh {0}"), + ("just automation-ci", "bash -c {0}"), + ("just automation-ci", "bash --rcfile outside {0}"), + ("just automation-ci", "bash {0} outside"), + ], +) +def test_gate_probe_refuses_unreviewed_shell_before_execution( + tmp_path: Path, script: str, shell: str +) -> None: + with pytest.raises(AssertionError): + _probe(tmp_path, script, recipe=False, sync_exit=0, gate_exit=0, shell=shell) + assert not list(tmp_path.iterdir()) From fa388f5d7fbcf0bac885fa38174f1c674c1dce5a Mon Sep 17 00:00:00 2001 From: Ashlen Date: Mon, 2 Nov 2026 21:49:25 -0700 Subject: [PATCH 7/7] fix: protect workflow isolation and recovery Separate internal caches from task identifiers and create missing fallback temporary storage. Preserve explicit TMPDIR values and retained interpreter bindings. Correct blocked-verification recovery guidance without relaxing admission. Isolate fixture Git selectors and ensure forked test children always exit. Add a locked development-toolchain audit alongside the runtime audit. Add real-process regressions, targeted properties for identifiers and TMPDIR, and receipt-bound return and source-preservation checks. --- automation/.gitignore | 1 + automation/AGENTS.md | 2 +- automation/CHANGELOG.md | 4 + automation/README.md | 13 ++- automation/justfile | 8 +- .../src/kickstarter_workflow/process.py | 2 + .../src/kickstarter_workflow/verification.py | 2 +- .../src/kickstarter_workflow/workflow.py | 2 +- automation/tests/conftest.py | 23 ++++- automation/tests/fixtures/audit_tool.py | 45 ++++++++++ .../tests/fixtures/environment_probe.py | 8 ++ automation/tests/fixtures/temporary_probe.py | 14 +++ .../tests/policy/test_development_audit.py | 67 ++++++++++++++ .../property/test_workflow_properties.py | 38 ++++++-- automation/tests/unit/test_cache_namespace.py | 37 ++++++++ .../tests/unit/test_fixture_environment.py | 88 +++++++++++++++++++ automation/tests/unit/test_process.py | 62 +++++++++++++ automation/tests/unit/test_safety.py | 31 ++++++- automation/tests/unit/test_transfer.py | 24 +++-- automation/tests/unit/test_transfer_child.py | 72 +++++++++++++++ 20 files changed, 522 insertions(+), 21 deletions(-) create mode 100644 automation/tests/fixtures/audit_tool.py create mode 100644 automation/tests/fixtures/environment_probe.py create mode 100644 automation/tests/fixtures/temporary_probe.py create mode 100644 automation/tests/policy/test_development_audit.py create mode 100644 automation/tests/unit/test_cache_namespace.py create mode 100644 automation/tests/unit/test_fixture_environment.py create mode 100644 automation/tests/unit/test_process.py create mode 100644 automation/tests/unit/test_transfer_child.py diff --git a/automation/.gitignore b/automation/.gitignore index a2d7a16..224d184 100644 --- a/automation/.gitignore +++ b/automation/.gitignore @@ -11,3 +11,4 @@ htmlcov/ coverage.xml .coverage requirements-audit.txt +requirements-audit-dev.txt diff --git a/automation/AGENTS.md b/automation/AGENTS.md index eb2565b..e78311b 100644 --- a/automation/AGENTS.md +++ b/automation/AGENTS.md @@ -21,7 +21,7 @@ just ci # the complete gate — must be green before pushing ## The gate -`just ci` is the COMPLETE fast gate: fmt-check, lint, typecheck, test+coverage, fuzz, policy, audit. Out-of-band: `just mutate` (mutation, non-gating); `just scan` (semgrep + gitleaks). +`just ci` is the COMPLETE fast gate: fmt-check, lint, typecheck, test+coverage, fuzz, policy, audit, audit-dev. The additive `audit-dev` checks the full locked development toolchain; the generated runtime-only `audit` remains separate. Both audits query the advisory service and block CI on failure. Out-of-band: `just mutate` (mutation, non-gating); `just scan` (semgrep + gitleaks). ## Add a behavior test diff --git a/automation/CHANGELOG.md b/automation/CHANGELOG.md index 1bc3ba4..6626a39 100644 --- a/automation/CHANGELOG.md +++ b/automation/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Explain source-drift recovery without suggesting return reconciliation can admit a blocked task. +- Create a private temporary fallback only when TMPDIR is absent, preserving explicit caller values. +- Separate internal tool caches from valid project/task identifiers without moving retained caches or interpreter bindings. - Bind explicitly declared fixture assets despite report/cache discovery exclusions, and require concrete fingerprints for reviewed transfer and effect accounting. - Reuse the documented `gh:maybebyte/python-kickstarter` Copier origin without relaxing canonical-cache or exact approved-revision validation. @@ -28,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- A required helper development-toolchain audit alongside the unchanged runtime-only audit. - JSON CLI and Python interface for task-owned prepare/resume, verification, reviewed return and explicit cleanup. - Real pinned Copier generation, existing-project reuse, disposable promotion, complete product mappings and private external records. - Helper-owned raw command results, real product test/branch-coverage evidence, candidate/mode/config/tool-bound receipts, and fail-closed stale/failed/interrupted evidence handling. diff --git a/automation/README.md b/automation/README.md index 52f8a5e..a4baa32 100644 --- a/automation/README.md +++ b/automation/README.md @@ -11,10 +11,12 @@ uv sync --locked just ci ``` -The unchanged generated gate includes Ruff ALL, recommended basedpyright, +The generated gate includes Ruff ALL, recommended basedpyright, unit tests with **85% branch coverage**, Hypothesis, policy tests, and the runtime -dependency audit. Runtime dependencies are empty; that audit is not a development -dependency audit. The locked development group includes Copier **9.15.2** and uv +dependency audit. Runtime dependencies are empty; that audit remains runtime-only. +The helper adds `audit-dev` to `ci` to audit the full locked development graph, +including the audit tool's dependencies, without re-resolving the exported pins. +Both audits require the advisory service and propagate failures. The locked development group includes Copier **9.15.2** and uv **0.13.4**. Integration tests invoke those tools and require Git and just on PATH, network access for task-local development environments, and a roomy `TMPDIR`. No helper test installs hooks. @@ -44,6 +46,11 @@ and, after verification, receipt identity and measured product tests/coverage. Diagnostics are local metadata containing absolute paths, not a portable identity or a shareable report. Keep the durable state directory outside source repositories and outside scratch/cache expiry policies. It must be owned by the caller, mode 0700. +Internal tool storage uses `.cache` beneath that directory, outside the project/task +identifier namespace. Existing `cache` contents are retained: older environments +can still bind interpreters there. No automatic cache migration or deletion occurs. +When `TMPDIR` is absent, the helper creates and validates a private fallback there; +explicit values, including an empty value, are passed through unchanged. ### Plan diff --git a/automation/justfile b/automation/justfile index 7212e00..dc0451e 100644 --- a/automation/justfile +++ b/automation/justfile @@ -4,7 +4,7 @@ default: @just --list # The complete local gate — every PR-blocking check reproducible locally. -ci: fmt-check lint typecheck test fuzz policy audit +ci: fmt-check lint typecheck test fuzz policy audit audit-dev @echo "ci: all gates passed" verify: ci @@ -38,6 +38,12 @@ audit: uv run pip-audit -r requirements-audit.txt rm -f requirements-audit.txt +# Audit the helper's full locked toolchain in addition to the generated runtime gate. +audit-dev: + uv export --frozen --no-emit-project --no-hashes -o requirements-audit-dev.txt + uv run pip-audit --no-deps --disable-pip -r requirements-audit-dev.txt + rm -f requirements-audit-dev.txt + # Slow / out-of-band (also runs in CI on its own cron, non-gating). mutate: uv run mutmut run || true diff --git a/automation/src/kickstarter_workflow/process.py b/automation/src/kickstarter_workflow/process.py index 2f9a4cb..855b3bd 100644 --- a/automation/src/kickstarter_workflow/process.py +++ b/automation/src/kickstarter_workflow/process.py @@ -54,6 +54,8 @@ def __init__(self, tools: Record, evidence: Path, cache: Path) -> None: if not path.is_absolute() or not path.is_file() or not os.access(path, os.X_OK): raise WorkflowError(f"missing executable tool: {name}") self.tools[name] = str(path.resolve()) + if "TMPDIR" not in os.environ: + private_directory(cache / "tmp") self.env: dict[str, str] = { "HOME": str(Path.home()), "PATH": os.pathsep.join( diff --git a/automation/src/kickstarter_workflow/verification.py b/automation/src/kickstarter_workflow/verification.py index 183a7ee..899e2b5 100644 --- a/automation/src/kickstarter_workflow/verification.py +++ b/automation/src/kickstarter_workflow/verification.py @@ -180,7 +180,7 @@ def verify(state: Record, task: Path, runner: Runner) -> Record: Path(text(plan, "source")), plan, destination=True ) != load(task / "source.json"): raise WorkflowError( - "source changed since preparation; return reconciliation or a decision is required" + "source changed since preparation; work is preserved. Prepare a new task from the current source, then reapply and verify the intended changes." ) git_root(runner, workspace) gate_policy(workspace, existing=plan["mode"] == "existing") diff --git a/automation/src/kickstarter_workflow/workflow.py b/automation/src/kickstarter_workflow/workflow.py index c9cafd9..2e021de 100644 --- a/automation/src/kickstarter_workflow/workflow.py +++ b/automation/src/kickstarter_workflow/workflow.py @@ -60,7 +60,7 @@ def _runner(self, plan: Record, task: Path) -> Runner: return Runner( object_value(plan, "tools"), task / ("run-" + uuid.uuid4().hex), - self.state_root / "cache", + self.state_root / ".cache", ) def _read_state(self, task: Path) -> Record: diff --git a/automation/tests/conftest.py b/automation/tests/conftest.py index 25c638f..b8d4c55 100644 --- a/automation/tests/conftest.py +++ b/automation/tests/conftest.py @@ -11,6 +11,26 @@ PIN = "37adbeeea53a242ed25db602a35b9d719d95c8ae" CANONICAL = "https://github.com/maybebyte/python-kickstarter.git" +# Repository selectors must not escape into fixture Git or Copier children. +# Keep transport/config overrides: documented-origin and controlled-config tests use them. +REPOSITORY_ENV = frozenset( + { + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_PREFIX", + "GIT_IMPLICIT_WORK_TREE", + "GIT_GRAFT_FILE", + "GIT_SHALLOW_FILE", + "GIT_NAMESPACE", + "GIT_REPLACE_REF_BASE", + "GIT_NO_REPLACE_OBJECTS", + } +) + def command(*args: str, cwd: Path) -> None: executable = shutil.which(args[0]) @@ -18,7 +38,8 @@ def command(*args: str, cwd: Path) -> None: old = Path.cwd() os.chdir(cwd) try: - pid = os.posix_spawn(executable, args, dict(os.environ)) + environment = {key: value for key, value in os.environ.items() if key not in REPOSITORY_ENV} + pid = os.posix_spawn(executable, args, environment) _, status = os.waitpid(pid, 0) assert os.waitstatus_to_exitcode(status) == 0 finally: diff --git a/automation/tests/fixtures/audit_tool.py b/automation/tests/fixtures/audit_tool.py new file mode 100644 index 0000000..efacf3b --- /dev/null +++ b/automation/tests/fixtures/audit_tool.py @@ -0,0 +1,45 @@ +"""Real locked export, controlled audit-service failure, inert unrelated CI tools.""" + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import cast + + +def main() -> int: + """Observe the complete gate at the uv process boundary.""" + args = sys.argv[1:] + if args and args[0] == "export": + failure = int(os.environ["EXPORT_EXIT"]) + if failure: + return failure + executable = os.environ["REAL_UV"] + pid = os.posix_spawn(executable, [executable, *args], dict(os.environ)) + _, status = os.waitpid(pid, 0) + return os.waitstatus_to_exitcode(status) + if args[:2] == ["run", "pip-audit"]: + parser = argparse.ArgumentParser() + _ = parser.add_argument("-r", "--requirement", required=True) + _ = parser.add_argument("--no-deps", action="store_true") + _ = parser.add_argument("--disable-pip", action="store_true") + options = cast("dict[str, object]", vars(parser.parse_args(args[2:]))) + requirement = options["requirement"] + assert isinstance(requirement, str) + content = Path(requirement).read_text() + packages = [ + line.split("==")[0] + for line in content.splitlines() + if "==" in line and not line.startswith("#") + ] + with Path(os.environ["AUDIT_OBSERVATIONS"]).open("a") as stream: + _ = stream.write(json.dumps(packages) + "\n") + return int(os.environ["AUDIT_EXIT"]) if packages else 0 + if args[:1] == ["run"] and len(args) > 1 and args[1] in {"ruff", "basedpyright", "pytest"}: + return 0 + return 29 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/automation/tests/fixtures/environment_probe.py b/automation/tests/fixtures/environment_probe.py new file mode 100644 index 0000000..a1c2cf2 --- /dev/null +++ b/automation/tests/fixtures/environment_probe.py @@ -0,0 +1,8 @@ +"""Report only the environment keys explicitly requested by a fixture test.""" + +import json +import os +import sys + +if __name__ == "__main__": + _ = sys.stdout.write(json.dumps({key: os.environ.get(key) for key in sys.argv[1:]}) + "\n") diff --git a/automation/tests/fixtures/temporary_probe.py b/automation/tests/fixtures/temporary_probe.py new file mode 100644 index 0000000..ae9c305 --- /dev/null +++ b/automation/tests/fixtures/temporary_probe.py @@ -0,0 +1,14 @@ +"""Observe the runner's temporary environment in a real child process.""" + +import json +import os +import sys +import tempfile +from pathlib import Path + +if __name__ == "__main__": + result = {"TMPDIR": os.environ["TMPDIR"]} + if sys.argv[1:] == ["create"]: + with tempfile.NamedTemporaryFile(dir=os.environ["TMPDIR"]) as stream: + result["parent"] = str(Path(stream.name).parent) + _ = sys.stdout.write(json.dumps(result) + "\n") diff --git a/automation/tests/policy/test_development_audit.py b/automation/tests/policy/test_development_audit.py new file mode 100644 index 0000000..86e3353 --- /dev/null +++ b/automation/tests/policy/test_development_audit.py @@ -0,0 +1,67 @@ +"""Helper CI retains runtime auditing and audits its locked development tools.""" + +import json +import os +import shutil +import sys +from pathlib import Path +from typing import cast + +import pytest + +from kickstarter_workflow.process import Runner, default_tools +from kickstarter_workflow.records import Value, load, object_value, text + +ROOT = Path(__file__).resolve().parents[2] + + +def test_complete_gate_requires_both_audits(tmp_path: Path) -> None: + runner = Runner(default_tools(), tmp_path / "evidence", tmp_path / "cache") + _ = runner.run(["just", "--dump", "--dump-format", "json"], ROOT) + recipes = object_value(load(runner.last_output), "recipes") + dependencies = object_value(recipes, "ci")["dependencies"] + assert isinstance(dependencies, list) + assert {"fmt-check", "lint", "typecheck", "test", "fuzz", "policy", "audit", "audit-dev"} <= { + text(item, "recipe") for item in dependencies if isinstance(item, dict) + } + + +@pytest.mark.parametrize(("export_exit", "audit_exit"), [(0, 0), (19, 0), (0, 17)]) +def test_complete_gate_audits_nonempty_development_export_and_propagates_failure( + tmp_path: Path, export_exit: int, audit_exit: int +) -> None: + for name in ("justfile", "pyproject.toml", "uv.lock"): + _ = shutil.copy2(ROOT / name, tmp_path / name) + executable = tmp_path / "bin/uv" + executable.parent.mkdir() + fixture = ROOT / "tests/fixtures/audit_tool.py" + _ = executable.write_text(f"#!{sys.executable}\n" + fixture.read_text()) + executable.chmod(0o700) + observations = tmp_path / "audits.jsonl" + runner = Runner(default_tools(), tmp_path / "evidence", tmp_path / "cache") + runner.env.update( + { + "PATH": str(executable.parent) + os.pathsep + runner.env["PATH"], + "REAL_UV": runner.tools["uv"], + "EXPORT_EXIT": str(export_exit), + "AUDIT_EXIT": str(audit_exit), + "AUDIT_OBSERVATIONS": str(observations), + } + ) + _ = runner.run(["just", "ci"], tmp_path, allow_failure=True) + assert runner.last_exit == (export_exit or audit_exit) + if export_exit: + assert not observations.exists() + else: + audits: list[list[str]] = [] + for line in observations.read_text().splitlines(): + packages = cast("Value", json.loads(line)) + assert isinstance(packages, list) + assert all(isinstance(item, str) for item in packages) + audits.append(cast("list[str]", packages)) + if not audit_exit: + assert [] in audits # The runtime-only graph remains honestly empty. + assert any( + {"copier", "uv", "pytest", "hypothesis", "ruff", "basedpyright"} <= set(packages) + for packages in audits + ) diff --git a/automation/tests/property/test_workflow_properties.py b/automation/tests/property/test_workflow_properties.py index 655a363..699fb2c 100644 --- a/automation/tests/property/test_workflow_properties.py +++ b/automation/tests/property/test_workflow_properties.py @@ -1,6 +1,7 @@ """Generated public-interface safety invariants, with isolated example state.""" import os +import sys import tempfile from pathlib import Path @@ -8,26 +9,53 @@ from hypothesis import example, given from hypothesis import strategies as st +from kickstarter_workflow.process import Runner, default_tools from kickstarter_workflow.records import Record, load, save, text from kickstarter_workflow.workflow import Workflow from tests.unit.test_mapped_assets import ASSETS, asset_plan +from tests.unit.test_process import PROBE pytestmark = pytest.mark.property +@given(value=st.text(alphabet="abcXYZ012/._- 'é\"\\\n\t", max_size=64)) +@example(value="") +def test_explicit_tmpdir_reaches_children_without_normalization(value: str) -> None: + with tempfile.TemporaryDirectory(dir=os.environ.get("TMPDIR")) as directory: + root = Path(directory) + with pytest.MonkeyPatch.context() as caller: + caller.setenv("TMPDIR", value) + runner = Runner( + {**default_tools(), "just": sys.executable}, root / "evidence", root / "cache" + ) + _ = runner.run(["just", str(PROBE)], root) + assert load(runner.last_output)["TMPDIR"] == value + assert not (root / "cache").exists() + + @given( - st.lists( + operations=st.lists( st.sampled_from(["prepare", "resume", "status", "verify", "return", "cleanup"]), min_size=1, max_size=15, - ) + ), + project=st.one_of(st.just("cache"), st.from_regex(r"[a-z][a-z0-9_-]{0,63}", fullmatch=True)), + task=st.one_of( + st.sampled_from(["uv", "python", "tmp"]), + st.from_regex(r"[a-z][a-z0-9_-]{0,63}", fullmatch=True), + ), ) -def test_disposable_lifecycle_never_grants_production_evidence(operations: list[str]) -> None: +@example(operations=["prepare", "resume", "cleanup"], project="cache", task="uv") +@example(operations=["prepare", "status", "cleanup"], project="cache", task="python") +@example(operations=["prepare", "verify", "return"], project="cache", task="tmp") +def test_disposable_lifecycle_never_grants_production_evidence( + operations: list[str], project: str, task: str +) -> None: with tempfile.TemporaryDirectory(dir=os.environ.get("TMPDIR")) as directory: workflow = Workflow(Path(directory) / "state") request: Record = { - "project": "example", - "task": "one", + "project": project, + "task": task, "mode": "disposable", "reason": "temporary diagnostic", } diff --git a/automation/tests/unit/test_cache_namespace.py b/automation/tests/unit/test_cache_namespace.py new file mode 100644 index 0000000..546c63f --- /dev/null +++ b/automation/tests/unit/test_cache_namespace.py @@ -0,0 +1,37 @@ +"""Project and task names never become tool cache storage.""" + +from pathlib import Path + +import pytest + +from kickstarter_workflow.records import Record, load, text +from kickstarter_workflow.workflow import Workflow + + +@pytest.mark.parametrize("task_name", ["uv", "python", "tmp", "ordinary-task"]) +def test_cache_project_tasks_do_not_alias_internal_storage( + tmp_path: Path, plan: Record, monkeypatch: pytest.MonkeyPatch, task_name: str +) -> None: + monkeypatch.delenv("TMPDIR", raising=False) + request: Record = { + **plan, + "project": "cache", + "task": task_name, + "repository_checks": [["uv", "cache", "dir"], ["uv", "python", "dir"]], + } + workflow = Workflow(tmp_path / "state") + prepared = workflow.dispatch("prepare", request) + assert prepared["status"] == "prepared", prepared + verified = workflow.dispatch("verify", request) + assert verified["status"] == "verified", verified + task = Path(text(prepared, "workspace")).parent + locations: dict[str, Path] = {} + for path in task.glob("run-*/**/*.json"): + record = load(path) + argv = record.get("argv") + if isinstance(argv, list) and argv[1:] in (["cache", "dir"], ["python", "dir"]): + assert record["exit"] == 0 + locations[str(argv[1])] = Path(Path(text(record, "output")).read_text().strip()) + assert set(locations) == {"cache", "python"} + assert all(not path.is_relative_to(tmp_path / "state/cache") for path in locations.values()) + assert workflow.dispatch("resume", request)["receipt"] == verified["receipt"] diff --git a/automation/tests/unit/test_fixture_environment.py b/automation/tests/unit/test_fixture_environment.py new file mode 100644 index 0000000..62bf8a6 --- /dev/null +++ b/automation/tests/unit/test_fixture_environment.py @@ -0,0 +1,88 @@ +"""Fixture Git operations stay in disposable repositories despite inherited selectors.""" + +import json +import sys +from pathlib import Path + +import pytest + +from tests.conftest import command + +SELECTORS = ( + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_PREFIX", + "GIT_IMPLICIT_WORK_TREE", + "GIT_GRAFT_FILE", + "GIT_SHALLOW_FILE", + "GIT_NAMESPACE", + "GIT_REPLACE_REF_BASE", + "GIT_NO_REPLACE_OBJECTS", +) + + +@pytest.mark.parametrize("selector", SELECTORS) +def test_fixture_command_clears_repository_selectors_and_preserves_foreign_repo( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capfd: pytest.CaptureFixture[str], + selector: str, +) -> None: + source, foreign = tmp_path / "fixture", tmp_path / "foreign" + for root, value in ((source, "fixture payload\n"), (foreign, "foreign sentinel\n")): + command("git", "init", "--quiet", "--template=", str(root), cwd=tmp_path) + _ = (root / "sentinel.txt").write_text(value) + command("git", "add", "sentinel.txt", cwd=root) + before = {path: path.read_bytes() for path in foreign.rglob("*") if path.is_file()} + modes = {path: path.stat().st_mode & 0o777 for path in [foreign, *foreign.rglob("*")]} + for path in modes: + path.chmod(0o500 if path.is_dir() else 0o400) + values = { + "GIT_DIR": str(foreign / ".git"), + "GIT_WORK_TREE": str(foreign), + "GIT_INDEX_FILE": str(foreign / ".git/index"), + "GIT_OBJECT_DIRECTORY": str(foreign / ".git/objects"), + "GIT_ALTERNATE_OBJECT_DIRECTORIES": str(foreign / ".git/objects"), + "GIT_COMMON_DIR": str(foreign / ".git"), + "GIT_GRAFT_FILE": str(foreign / "grafts"), + "GIT_SHALLOW_FILE": str(foreign / "shallow"), + "GIT_IMPLICIT_WORK_TREE": "0", + "GIT_NO_REPLACE_OBJECTS": "1", + } + _ = capfd.readouterr() + try: + with monkeypatch.context() as inherited: + inherited.setenv(selector, values.get(selector, "foreign")) + command("git", "add", "sentinel.txt", cwd=source) + command("git", "show", ":sentinel.txt", cwd=source) + assert capfd.readouterr().out == "fixture payload\n" + probe = Path(__file__).resolve().parents[1] / "fixtures/environment_probe.py" + command(sys.executable, str(probe), selector, cwd=source) + assert json.loads(capfd.readouterr().out) == {selector: None} + assert {path: path.read_bytes() for path in foreign.rglob("*") if path.is_file()} == before + finally: + for path, mode in modes.items(): + path.chmod(mode) + + +def test_fixture_command_preserves_controlled_git_configuration( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capfd: pytest.CaptureFixture[str] +) -> None: + config = tmp_path / "gitconfig" + _ = config.write_text("[fixture]\n origin = global-control\n") + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config)) + monkeypatch.setenv("GIT_CONFIG_COUNT", "1") + monkeypatch.setenv("GIT_CONFIG_KEY_0", "fixture.transport") + monkeypatch.setenv("GIT_CONFIG_VALUE_0", "offline-control") + monkeypatch.setenv("GIT_ALLOW_PROTOCOL", "file") + command("git", "config", "--get", "fixture.origin", cwd=tmp_path) + assert capfd.readouterr().out == "global-control\n" + command("git", "config", "--get", "fixture.transport", cwd=tmp_path) + assert capfd.readouterr().out == "offline-control\n" + probe = Path(__file__).resolve().parents[1] / "fixtures/environment_probe.py" + command(sys.executable, str(probe), "GIT_ALLOW_PROTOCOL", cwd=tmp_path) + assert json.loads(capfd.readouterr().out) == {"GIT_ALLOW_PROTOCOL": "file"} diff --git a/automation/tests/unit/test_process.py b/automation/tests/unit/test_process.py new file mode 100644 index 0000000..3ccdb4a --- /dev/null +++ b/automation/tests/unit/test_process.py @@ -0,0 +1,62 @@ +"""Temporary-directory behavior observed by a real runner child.""" + +import sys +from pathlib import Path + +import pytest + +from kickstarter_workflow.process import Runner, default_tools +from kickstarter_workflow.records import WorkflowError, load, text + +PROBE = Path(__file__).resolve().parents[1] / "fixtures/temporary_probe.py" + + +def test_absent_tmpdir_is_created_privately_before_child_execution( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("TMPDIR", raising=False) + runner = Runner( + {**default_tools(), "just": sys.executable}, tmp_path / "evidence", tmp_path / "cache" + ) + _ = runner.run(["just", str(PROBE), "create"], tmp_path) + observed = load(runner.last_output) + directory = Path(text(observed, "TMPDIR")) + assert directory.is_relative_to(tmp_path / "cache") + assert observed["parent"] == str(directory) + assert directory.stat().st_mode & 0o777 == 0o700 + + +@pytest.mark.parametrize("value", ["", "relative directory", "/caller/does-not-exist"]) +def test_explicit_tmpdir_is_verbatim_without_touching_fallback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, value: str +) -> None: + monkeypatch.setenv("TMPDIR", value) + cache = tmp_path / "cache" + _ = cache.write_bytes(b"Caller-owned obstruction; do not validate or replace.") + runner = Runner({**default_tools(), "just": sys.executable}, tmp_path / "evidence", cache) + _ = runner.run(["just", str(PROBE)], tmp_path) + assert load(runner.last_output)["TMPDIR"] == value + assert cache.read_bytes() == b"Caller-owned obstruction; do not validate or replace." + + +@pytest.mark.parametrize("unsafe", ["shared", "symlink", "file"]) +def test_absent_tmpdir_refuses_unsafe_fallback_without_repairing_it( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, unsafe: str +) -> None: + monkeypatch.delenv("TMPDIR", raising=False) + cache = tmp_path / "cache" + cache.mkdir(mode=0o700) + fallback = cache / "tmp" + if unsafe == "shared": + fallback.mkdir() + fallback.chmod(0o755) + elif unsafe == "symlink": + target = tmp_path / "other" + target.mkdir(mode=0o700) + fallback.symlink_to(target, target_is_directory=True) + else: + _ = fallback.write_bytes(b"retained") + before = fallback.lstat() + with pytest.raises((WorkflowError, FileExistsError)): + _ = Runner(default_tools(), tmp_path / "evidence", cache) + assert fallback.lstat() == before diff --git a/automation/tests/unit/test_safety.py b/automation/tests/unit/test_safety.py index 491c196..5489d68 100644 --- a/automation/tests/unit/test_safety.py +++ b/automation/tests/unit/test_safety.py @@ -213,7 +213,34 @@ def test_source_changes_are_not_certified_as_unchanged(tmp_path: Path, plan: Rec prepared = workflow.dispatch("prepare", plan) assert prepared["status"] == "prepared", prepared source = Path(text(plan, "source")) - _ = (source / "app.py").write_text('"""Newer source belongs to another task."""\n') + workspace = Path(text(prepared, "workspace")) + app = workspace / "product/app.py" + _ = app.write_text(app.read_text().replace("value * 2", "value + value")) + verified = workflow.dispatch("verify", plan) + assert verified["status"] == "verified", verified + receipt = workspace.parent / "receipts" / (text(verified, "receipt") + ".json") + retained = receipt.read_bytes() + candidate = app.read_bytes() + snapshot = (workspace.parent / "source.json").read_bytes() + original = (source / "app.py").read_text() + newer = original.replace("A maintained calculation.", "A calculation another task now owns.") + assert newer != original + _ = (source / "app.py").write_text(newer) result = workflow.dispatch("verify", plan) assert result["status"] == "blocked", result - assert "source changed" in text(result, "reason") + assert isinstance(result["reason"], str) + assert workflow.dispatch("status", plan)["status"] == "blocked" + review: Record = { + "receipt": text(verified, "receipt"), + "reviewer": "independent-test-review", + "reason": "Named product delta reviewed.", + "allowlist": ["app.py"], + } + refused = workflow.dispatch("return", {"project": "sample", "task": "one", "review": review}) + assert refused["status"] == "blocked", refused + assert (source / "app.py").read_bytes() == newer.encode() + assert app.read_bytes() == candidate + assert (workspace.parent / "source.json").read_bytes() == snapshot + assert not (workspace.parent / "return.json").exists() + assert sorted(receipt.parent.iterdir()) == [receipt] + assert receipt.read_bytes() == retained diff --git a/automation/tests/unit/test_transfer.py b/automation/tests/unit/test_transfer.py index 36f30a6..7141f25 100644 --- a/automation/tests/unit/test_transfer.py +++ b/automation/tests/unit/test_transfer.py @@ -2,7 +2,9 @@ import os import signal +import traceback from pathlib import Path +from typing import NoReturn import pytest @@ -23,6 +25,21 @@ def review(receipt: str, *files: str) -> Record: } +def return_child(workflow: Workflow, request: Record) -> NoReturn: + exit_code = 75 + try: + _ = signal.signal(signal.SIGINT, signal.default_int_handler) + _ = workflow.dispatch("return", request) + exit_code = 74 + except KeyboardInterrupt: + exit_code = 73 + except BaseException: + traceback.print_exc() + raise + finally: + os._exit(exit_code) + + @pytest.mark.parametrize("drift", ["content", "mode"]) def test_return_refuses_unreviewed_payload_and_conflicting_newer_source( tmp_path: Path, plan: Record, drift: str @@ -86,12 +103,7 @@ def interrupt(path: Path, target: Path) -> Path: fault.setattr(Path, "replace", interrupt) pid = os.fork() if pid == 0: - _ = signal.signal(signal.SIGINT, signal.default_int_handler) - try: - _ = workflow.dispatch("return", request) - except KeyboardInterrupt: - os._exit(73) - os._exit(74) + return_child(workflow, request) _, status = os.waitpid(pid, 0) assert os.waitstatus_to_exitcode(status) == 73 observed = workflow.dispatch("status", plan) diff --git a/automation/tests/unit/test_transfer_child.py b/automation/tests/unit/test_transfer_child.py new file mode 100644 index 0000000..dd7886e --- /dev/null +++ b/automation/tests/unit/test_transfer_child.py @@ -0,0 +1,72 @@ +"""The interruption fixture terminates its child even when setup or dispatch fails.""" + +import contextlib +import os +import signal +import time +from pathlib import Path + +import pytest + +from kickstarter_workflow.workflow import Workflow +from tests.unit.test_transfer import return_child + + +def wait_for_child(pid: int) -> int: + status: int | None = None + try: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + child, observed = os.waitpid(pid, os.WNOHANG) + if child: + status = observed + break + time.sleep(0.01) + assert status is not None, "child did not terminate without observer intervention" + return os.waitstatus_to_exitcode(status) + finally: + if status is None: + with contextlib.suppress(ProcessLookupError): + os.kill(pid, signal.SIGKILL) + _ = os.waitpid(pid, 0) + + +@pytest.mark.parametrize("failure", ["setup", "dispatch", "system-exit", "interrupt", "normal"]) +def test_return_child_terminates_without_falling_through( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capfd: pytest.CaptureFixture[str], failure: str +) -> None: + workflow = Workflow(tmp_path / "state") + escaped = tmp_path / "escaped" + + def fail_setup(*_args: object) -> None: + raise RuntimeError("fixture-setup-fault") + + def dispatch(*_args: object) -> dict[str, str]: + faults = {"dispatch": RuntimeError("fixture-dispatch-fault"), "system-exit": SystemExit(42)} + if failure in faults: + raise faults[failure] + if failure == "interrupt": + os.kill(os.getpid(), signal.SIGINT) + return {"status": "blocked"} + + monkeypatch.setattr(workflow, "dispatch", dispatch) + if failure == "setup": + monkeypatch.setattr(signal, "signal", fail_setup) + pid = os.fork() + if pid == 0: + try: + return_child(workflow, {}) + except BaseException: + _ = escaped.write_text("child escaped into its caller") + raise + finally: + # Contain a broken subject; this distinct status must fail the test. + os._exit(99) + expected = {"interrupt": 73, "normal": 74}.get(failure, 75) + assert wait_for_child(pid) == expected + assert not escaped.exists() + diagnostic = capfd.readouterr().err + if failure in {"setup", "dispatch"}: + assert f"RuntimeError: fixture-{failure}-fault" in diagnostic + elif failure == "system-exit": + assert "SystemExit: 42" in diagnostic