From 4b559deaee596311c1fc26cf1497dce674b50154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 12:37:01 -0700 Subject: [PATCH 01/35] =?UTF-8?q?=F0=9F=94=A5=20feat(hooks)!:=20drop=20the?= =?UTF-8?q?=20hook=20cooldown=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove check-hook-update-cooldown, recheck-hook-update-cooldown, the recheck-hook-cooldown.yaml workflow and check_hook_config.py. Dependabot cooldowns now gate hook revisions, so the PR job and its gate exception go. --- .github/dependabot.yml | 10 - .github/workflows/check.yaml | 24 +- .github/workflows/recheck-hook-cooldown.yaml | 18 - README.md | 50 +- changelog.d/+hook-cooldown.breaking.md | 3 + check-hook-update-cooldown/action.yml | 314 ------ recheck-hook-update-cooldown/action.yml | 79 -- .../fixtures/pre-bootstrap.yaml | 40 - .../test_cooldown.py | 933 ------------------ .../test_hook_config.py | 570 ----------- .../test_workflow.py | 47 - tests/check_workflow/test_gate.py | 46 + tests/conftest.py | 4 - .../test_recheck.py | 359 ------- tools/check_hook_config.py | 248 ----- 15 files changed, 53 insertions(+), 2692 deletions(-) delete mode 100644 .github/workflows/recheck-hook-cooldown.yaml create mode 100644 changelog.d/+hook-cooldown.breaking.md delete mode 100644 check-hook-update-cooldown/action.yml delete mode 100644 recheck-hook-update-cooldown/action.yml delete mode 100644 tests/check_hook_update_cooldown/fixtures/pre-bootstrap.yaml delete mode 100644 tests/check_hook_update_cooldown/test_cooldown.py delete mode 100644 tests/check_hook_update_cooldown/test_hook_config.py create mode 100644 tests/check_workflow/test_gate.py delete mode 100644 tests/recheck_hook_update_cooldown/test_recheck.py delete mode 100644 tools/check_hook_config.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9d79c41..f9f1e3c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,13 +10,3 @@ updates: labels: - dependencies - skip news - - package-ecosystem: github-actions - directory: /check-hook-update-cooldown - cooldown: - default-days: 7 - schedule: - interval: weekly - day: tuesday - labels: - - dependencies - - skip news diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 2fe0b20..54ff96d 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -126,32 +126,12 @@ jobs: pyproject.toml - name: Check Python types run: uvx --with tox-uv tox run -e type - hook-cooldown: - if: github.event_name == 'pull_request' - runs-on: ubuntu-26.04 - permissions: - actions: read - contents: read - pull-requests: read - steps: - - uses: $/check-hook-update-cooldown - with: - release-app-client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} gate: name: ${{ github.event_name == 'pull_request' && 'Required checks pass' || 'Required checks pass (non-PR)' }} if: ${{ !cancelled() }} - needs: [check, shell, test, type, hook-cooldown] + needs: [check, shell, test, type] runs-on: ubuntu-26.04 steps: - env: NEEDS_JSON: ${{ toJSON(needs) }} - EVENT_NAME: ${{ github.event_name }} - run: | - jq -e ' - all( - to_entries[]; - .value.result == "success" or - ($ENV.EVENT_NAME != "pull_request" and .value.result == "skipped" and - .key == "hook-cooldown") - ) - ' <<<"$NEEDS_JSON" + run: jq -e 'all(to_entries[]; .value.result == "success")' <<<"$NEEDS_JSON" diff --git a/.github/workflows/recheck-hook-cooldown.yaml b/.github/workflows/recheck-hook-cooldown.yaml deleted file mode 100644 index 4686f4a..0000000 --- a/.github/workflows/recheck-hook-cooldown.yaml +++ /dev/null @@ -1,18 +0,0 @@ -name: Recheck hook cooldown -on: - schedule: - - cron: "17 7 * * *" - workflow_dispatch: -permissions: {} -concurrency: - group: recheck-hook-cooldown-${{ github.ref }} - cancel-in-progress: true -jobs: - recheck: - runs-on: ubuntu-26.04 - permissions: - actions: write - contents: read - pull-requests: read - steps: - - uses: $/recheck-hook-update-cooldown diff --git a/README.md b/README.md index 5e15200..530673d 100644 --- a/README.md +++ b/README.md @@ -190,26 +190,9 @@ The weekly updater freezes eligible hook revisions with `prek update --freeze -- `.pre-commit-config.yaml` changes. The generation job has read-only repository permissions; the separate publish job checks the patch paths and file modes before obtaining an App token. -`check-hook-update-cooldown` checks every PR that changes `.pre-commit-config.yaml`. Put it in the caller's -`pull_request` workflow with `actions: read`, `contents: read`, and `pull-requests: read`, then include it in the -required aggregate. It reads its PR number and head SHA from the event, compares them with the live PR, and reads the -proposed configuration from that exact head, including a fork head. It checks the complete Python hook dependency -closure at the configured `minimum-days` cutoff and requires each changed revision to have appeared in a GitHub PR -workflow run at least that many days earlier. The default is seven days. A force push does not reset an unchanged -revision's age or give a new revision the old one's age. GitHub can omit pull request links from workflow runs. For -those runs, the Action observes the exact head repository, branch, commit, and workflow after the PR opened; two PRs -from the same head can share that code observation. Human PRs may add hooks or change their options under the same -revision and dependency checks. A human PR may add or change managed pins when each new pin is an exact PyPI release at -least `minimum-days` old; the complete closure check still applies. Other bot PRs may change revisions only. On -`automation/update-release-tools`, the Action verifies the bot against `RELEASE_APP_CLIENT_ID` and permits exact, cooled -PyPI dependency pins generated by the release App. Every external hook revision must be a full lowercase commit SHA. -Only Python hooks may declare `additional_dependencies`, because only those get exact, cooled pins. A revision with new -Python dependencies stays blocked until its managed pins are updated. Tagged source jobs check frozen revisions and the -complete Python dependency closure before running `prek`. - Every shared `prek` command passes `--config .pre-commit-config.yaml`. Without it, prek prefers a root `prek.toml` and -also runs nested configurations, which no guard checks. The cooldown check and `verify-frozen-hooks` therefore reject a -`prek.toml` anywhere, a `.pre-commit-config.yml`, and a `.pre-commit-config.yaml` below the root. +also runs nested configurations, which no guard checks. `verify-frozen-hooks` therefore rejects a `prek.toml` anywhere, +a `.pre-commit-config.yml`, and a `.pre-commit-config.yaml` below the root. On a tag, `verify-frozen-hooks` also checks when each new external `(repository, commit)` pair was first present in GitHub. It compares the tag with the previous published release in the same tag stream and on the first-parent release @@ -252,35 +235,6 @@ For example, a tagged source job that also publishes from a PGO lock checks both For a committed native uv lock, set `keep-uv-lock: true` in this step. The full project lock and `uv.lock` must then agree with the same project configuration. -```yaml -hook-cooldown: - if: github.event_name == 'pull_request' - runs-on: ubuntu-26.04 - permissions: - actions: read - contents: read - pull-requests: read - steps: - - uses: tox-dev/github-workflows/check-hook-update-cooldown@SHA - with: - release-app-client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} -``` - -Run the PR workflow on `opened`, `reopened`, and `synchronize`; the cooldown check must succeed on PRs and may skip on -push. The Action has no output or write side effect. Schedule `recheck-hook-update-cooldown` daily with -`actions: write`, `contents: read`, and `pull-requests: read`. It reruns eligible failed cooldown jobs at the current -head, including human and fork PRs. GitHub accepts reruns for 30 days. A failed rerun is retried on the next schedule -while eligible. The [caller here](.github/workflows/recheck-hook-cooldown.yaml) shows the trigger and permissions. The -Action reruns the `hook-cooldown` job in `check.yaml`. - -If the job has a different name or workflow filename, pass both to the scheduled caller: - -```yaml -with: - workflow: ci.yaml - job-name: check-hook-updates -``` - ## Start with a caller The [release preparation workflow](.github/workflows/prepare-release.yaml) in this repository calls the reusable diff --git a/changelog.d/+hook-cooldown.breaking.md b/changelog.d/+hook-cooldown.breaking.md new file mode 100644 index 0000000..b6c7d0b --- /dev/null +++ b/changelog.d/+hook-cooldown.breaking.md @@ -0,0 +1,3 @@ +Remove the `check-hook-update-cooldown` and `recheck-hook-update-cooldown` Actions and the `recheck-hook-cooldown.yaml` +workflow. Pull requests that change `.pre-commit-config.yaml` are no longer checked for hook revision age or dependency +closure cooling; drop the `hook-cooldown` job and its scheduled rerun from callers. diff --git a/check-hook-update-cooldown/action.yml b/check-hook-update-cooldown/action.yml deleted file mode 100644 index 1601a71..0000000 --- a/check-hook-update-cooldown/action.yml +++ /dev/null @@ -1,314 +0,0 @@ -name: Check hook update cooldown -description: Require seven days before a hook update pull request can merge -inputs: - minimum-days: - description: Minimum age of a proposed hook revision - default: "7" - release-app-client-id: - description: Client ID of the App allowed to update managed hook dependencies - default: "" -runs: - using: composite - steps: - - id: identity - name: Verify the pull request identity - shell: bash - env: - MINIMUM_DAYS: ${{ inputs.minimum-days }} - GH_TOKEN: ${{ github.token }} - RELEASE_APP_CLIENT_ID: ${{ inputs.release-app-client-id }} - run: | - set -euo pipefail - REPOSITORY=$GITHUB_REPOSITORY - PR_NUMBER=$(jq -r .number "$GITHUB_EVENT_PATH") - HEAD_SHA=$(jq -r .pull_request.head.sha "$GITHUB_EVENT_PATH") - case "$MINIMUM_DAYS" in - '' | 0* | *[!0-9]*) MINIMUM_DAYS=0 ;; - esac - if ((MINIMUM_DAYS < 7)); then - echo '::error::minimum-days must be a whole number of at least seven' >&2 - exit 1 - fi - gh api "repos/$REPOSITORY/pulls/$PR_NUMBER" >"$RUNNER_TEMP/hook-update-pr.json" - jq -e --arg repository "$REPOSITORY" --arg head "$HEAD_SHA" ' - .state == "open" and .base.repo.full_name == $repository and - .head.sha == $head and - (.user.login | type == "string") - ' "$RUNNER_TEMP/hook-update-pr.json" >/dev/null - gh api --paginate "repos/$REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[] | [.status, .filename] | @tsv' \ - >"$RUNNER_TEMP/hook-update-files" - while IFS=$'\t' read -r status file; do - name=${file##*/} - if [[ "$status" != removed && ("$name" == prek.toml || "$name" == .pre-commit-config.yml || - ("$name" == .pre-commit-config.yaml && "$file" != .pre-commit-config.yaml)) ]]; then - echo "::error::hooks must be configured only in the root .pre-commit-config.yaml: $file" >&2 - exit 1 - fi - done <"$RUNNER_TEMP/hook-update-files" - if cut -f2 "$RUNNER_TEMP/hook-update-files" | grep -Fxq .pre-commit-config.yaml; then - if [[ $(jq -r .user.type "$RUNNER_TEMP/hook-update-pr.json") == Bot ]]; then - jq -e --arg repository "$REPOSITORY" '.head.repo.full_name == $repository' \ - "$RUNNER_TEMP/hook-update-pr.json" >/dev/null || { - echo '::error::bot hook updates must come from this repository' >&2 - exit 1 - } - fi - if [[ $(jq -r .user.type "$RUNNER_TEMP/hook-update-pr.json") == User ]]; then - echo 'human=true' >>"$GITHUB_OUTPUT" - fi - if [[ $(jq -r .head.ref "$RUNNER_TEMP/hook-update-pr.json") == automation/update-release-tools ]]; then - author=$(jq -r .user.login "$RUNNER_TEMP/hook-update-pr.json") - [[ "$author" == *'[bot]' && -n "$RELEASE_APP_CLIENT_ID" ]] || { - echo '::error::release tool branch requires the configured release App' >&2 - exit 1 - } - slug=${author%'[bot]'} - gh api "apps/$slug" >"$RUNNER_TEMP/release-app.json" - jq -e --arg slug "$slug" --arg client "$RELEASE_APP_CLIENT_ID" ' - .slug == $slug and .client_id == $client - ' "$RUNNER_TEMP/release-app.json" >/dev/null || { - echo '::error::release tool branch has a different App identity' >&2 - exit 1 - } - echo 'managed=true' >>"$GITHUB_OUTPUT" - fi - gh api --paginate -X GET "repos/$REPOSITORY/actions/runs" -f event=pull_request \ - -f head_sha="$HEAD_SHA" -f per_page=100 >"$RUNNER_TEMP/hook-update-runs.jsonl" - workflow_path=${GITHUB_WORKFLOW_REF#"$GITHUB_REPOSITORY/"} - workflow_path=${workflow_path%@*} - if jq -se --arg repository "$(jq -r .head.repo.full_name "$RUNNER_TEMP/hook-update-pr.json")" \ - --arg branch "$(jq -r .head.ref "$RUNNER_TEMP/hook-update-pr.json")" \ - --arg head "$HEAD_SHA" --arg workflow "$workflow_path" \ - --argjson number "$PR_NUMBER" --argjson days "$MINIMUM_DAYS" \ - --argjson opened "$(jq -r '.created_at | fromdateiso8601' "$RUNNER_TEMP/hook-update-pr.json")" ' - [.[].workflow_runs[] | select( - .event == "pull_request" and .head_repository.full_name == $repository and - .head_branch == $branch and .head_sha == $head and (.path | split("@")[0]) == $workflow and - (any(.pull_requests[]; .number == $number) or - (.pull_requests == [] and (.created_at | fromdateiso8601) >= $opened)) - ) | .created_at | fromdateiso8601] as $observed | - ($observed | length) > 0 and ($observed | min) <= now - ($days * 86400) - ' "$RUNNER_TEMP/hook-update-runs.jsonl" >/dev/null; then - echo 'cooled=true' >>"$GITHUB_OUTPUT" - fi - echo 'changed=true' >>"$GITHUB_OUTPUT" - fi - - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - if: steps.identity.outputs.changed == 'true' - with: - version: latest-known - python-version: "3.15" - enable-cache: false - - uses: $/install-locked-tools - if: steps.identity.outputs.changed == 'true' - with: - groups: release-hook-check release-notes lock-reader - lock-source: shared - venv: ${{ runner.temp }}/hook-tools - - name: Read the base and proposed hook configurations - if: steps.identity.outputs.changed == 'true' - shell: bash - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - REPOSITORY=$GITHUB_REPOSITORY - HEAD_SHA=$(jq -r .pull_request.head.sha "$GITHUB_EVENT_PATH") - base_sha=$(jq -r .base.sha "$RUNNER_TEMP/hook-update-pr.json") - head_repository=$(jq -r .head.repo.full_name "$RUNNER_TEMP/hook-update-pr.json") - gh api "repos/$REPOSITORY/contents/.pre-commit-config.yaml?ref=$base_sha" --jq .content | - base64 --decode >"$RUNNER_TEMP/base-hooks.yaml" - gh api "repos/$head_repository/contents/.pre-commit-config.yaml?ref=$HEAD_SHA" --jq .content | - base64 --decode >"$RUNNER_TEMP/proposed-hooks.yaml" - git init -q "$RUNNER_TEMP/hook-cooldown" - - name: Reject movable hook revisions - if: steps.identity.outputs.changed == 'true' - shell: bash - run: >- - uv run --no-project --python "$RUNNER_TEMP/hook-tools" python "$GITHUB_ACTION_PATH/../tools/check_frozen_hooks.py" "$RUNNER_TEMP/proposed-hooks.yaml" - - name: Reject non-revision hook changes - if: steps.identity.outputs.changed == 'true' - shell: bash - env: - MANAGED: ${{ steps.identity.outputs.managed }} - HUMAN: ${{ steps.identity.outputs.human }} - MINIMUM_DAYS: ${{ inputs.minimum-days }} - run: | - set -euo pipefail - uv run --no-project --python "$RUNNER_TEMP/hook-tools" python \ - "$GITHUB_ACTION_PATH/../tools/check_hook_config.py" \ - "$RUNNER_TEMP/base-hooks.yaml" "$RUNNER_TEMP/proposed-hooks.yaml" \ - --managed "$MANAGED" --human "$HUMAN" --minimum-days "$MINIMUM_DAYS" - - name: Verify managed hook dependency closure - if: steps.identity.outputs.changed == 'true' - shell: bash - env: - MINIMUM_DAYS: ${{ inputs.minimum-days }} - run: | - set -euo pipefail - mkdir -p "$RUNNER_TEMP/hook-closure" - cp "$RUNNER_TEMP/proposed-hooks.yaml" "$RUNNER_TEMP/hook-closure/.pre-commit-config.yaml" - cd "$RUNNER_TEMP/hook-closure" - uv run --no-project --python "$RUNNER_TEMP/hook-tools" release-creator hooks \ - --check --minimum-days "$MINIMUM_DAYS" - - name: Compare proposed hook revisions - if: steps.identity.outputs.changed == 'true' - shell: bash - env: - HUMAN: ${{ steps.identity.outputs.human }} - run: | - set -euo pipefail - for config in base proposed; do - uv run --no-project --python "$RUNNER_TEMP/hook-tools" yq -c \ - '[.repos[] | select(.repo != "local" and .repo != "meta") | {repo, rev}]' \ - "$RUNNER_TEMP/$config-hooks.yaml" >"$RUNNER_TEMP/$config-hooks.json" - done - jq -e -n \ - --slurpfile base "$RUNNER_TEMP/base-hooks.json" \ - --slurpfile proposed "$RUNNER_TEMP/proposed-hooks.json" --arg human "$HUMAN" ' - def identities($entries): - [$entries | to_entries[] | . as $entry | - {repo: .value.repo, rev: .value.rev, - occurrence: ([$entries[:$entry.key][] | select(.repo == $entry.value.repo)] | length)}]; - identities($base[0]) as $old | - identities($proposed[0]) as $new | - if $human != "true" and - ($old | map(.repo)) != ($new | map(.repo)) - then error("hook repository entries changed") - else [$new[] | . as $entry | - select(.rev != ([$old[] | - select(.repo == $entry.repo and .occurrence == $entry.occurrence) | .rev][0] // null)) | - [.repo, .rev, .occurrence]] end - ' >"$RUNNER_TEMP/older-hook-revisions.json" - jq -r '.[] | @tsv' "$RUNNER_TEMP/older-hook-revisions.json" >"$RUNNER_TEMP/older-hook-revisions" - - name: Find prior pull request heads - if: steps.identity.outputs.changed == 'true' && steps.identity.outputs.cooled != 'true' - shell: bash - env: - MINIMUM_DAYS: ${{ inputs.minimum-days }} - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - REPOSITORY=$GITHUB_REPOSITORY - PR_NUMBER=$(jq -r .number "$GITHUB_EVENT_PATH") - branch=$(jq -r .head.ref "$RUNNER_TEMP/hook-update-pr.json") - cutoff=$(jq -nr --argjson days "$MINIMUM_DAYS" 'now - $days * 86400 | todateiso8601') - gh api --paginate -X GET "repos/$REPOSITORY/actions/runs" -f event=pull_request \ - -f branch="$branch" -f "created=<$cutoff" -f per_page=100 >"$RUNNER_TEMP/hook-branch-runs.jsonl" - workflow_path=${GITHUB_WORKFLOW_REF#"$GITHUB_REPOSITORY/"} - workflow_path=${workflow_path%@*} - jq -sr --arg repository "$(jq -r .head.repo.full_name "$RUNNER_TEMP/hook-update-pr.json")" \ - --arg branch "$branch" --arg workflow "$workflow_path" \ - --argjson number "$PR_NUMBER" --argjson days "$MINIMUM_DAYS" \ - --argjson opened "$(jq -r '.created_at | fromdateiso8601' "$RUNNER_TEMP/hook-update-pr.json")" ' - [.[].workflow_runs[] | select( - .event == "pull_request" and .head_repository.full_name == $repository and - .head_branch == $branch and (.path | split("@")[0]) == $workflow and - (any(.pull_requests[]; .number == $number) or - (.pull_requests == [] and (.created_at | fromdateiso8601) >= $opened)) and - (.created_at | fromdateiso8601) <= now - ($days * 86400) - ) | .head_sha] | unique[] - ' "$RUNNER_TEMP/hook-branch-runs.jsonl" >"$RUNNER_TEMP/older-heads" - - name: Read prior hook revisions - if: steps.identity.outputs.changed == 'true' && steps.identity.outputs.cooled != 'true' - shell: bash - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - REPOSITORY=$(jq -r .head.repo.full_name "$RUNNER_TEMP/hook-update-pr.json") - : >"$RUNNER_TEMP/observed-revisions.jsonl" - while IFS= read -r head; do - if gh api "repos/$REPOSITORY/contents/.pre-commit-config.yaml?ref=$head" --jq .content \ - >"$RUNNER_TEMP/older-hooks.b64" 2>"$RUNNER_TEMP/older-hooks-error"; then - base64 --decode <"$RUNNER_TEMP/older-hooks.b64" >"$RUNNER_TEMP/older-hooks.yaml" - elif grep -Fq '(HTTP 404)' "$RUNNER_TEMP/older-hooks-error"; then - continue - else - cat "$RUNNER_TEMP/older-hooks-error" >&2 - exit 1 - fi - if ! uv run --no-project --python "$RUNNER_TEMP/hook-tools" yq -c ' - [.repos[] | select(.repo != "local" and .repo != "meta") | {repo, rev}] - ' "$RUNNER_TEMP/older-hooks.yaml" >"$RUNNER_TEMP/older-revisions.json"; then - echo "::warning::skip malformed hook config at $head" >&2 - continue - fi - jq -c ' - . as $entries | to_entries[] | . as $entry | - {repo: .value.repo, rev: .value.rev, - occurrence: ([$entries[:$entry.key][] | select(.repo == $entry.value.repo)] | length)} - ' "$RUNNER_TEMP/older-revisions.json" >>"$RUNNER_TEMP/observed-revisions.jsonl" - done <"$RUNNER_TEMP/older-heads" - - name: Verify the first GitHub observation of each revision - if: steps.identity.outputs.changed == 'true' && steps.identity.outputs.cooled != 'true' - shell: bash - run: | - jq -n --slurpfile base "$RUNNER_TEMP/base-hooks.json" \ - --slurpfile proposed "$RUNNER_TEMP/proposed-hooks.json" ' - def identities($entries): - [$entries | to_entries[] | . as $entry | - {repo: .value.repo, rev: .value.rev, - occurrence: ([$entries[:$entry.key][] | select(.repo == $entry.value.repo)] | length)}]; - identities($base[0]) as $old | - [identities($proposed[0])[] | . as $entry | - select(.rev != ([$old[] | - select(.repo == $entry.repo and .occurrence == $entry.occurrence) | .rev][0] // null))] - ' >"$RUNNER_TEMP/proposed-revisions.json" - if ! jq -se --slurpfile proposed "$RUNNER_TEMP/proposed-revisions.json" ' - . as $observed | - all($proposed[0][]; . as $revision | - any($observed[]; - .repo == $revision.repo and .occurrence == $revision.occurrence and .rev == $revision.rev)) - ' "$RUNNER_TEMP/observed-revisions.jsonl" >/dev/null; then - echo '::error::hook revision has not been observed on this pull request for seven days' >&2 - exit 1 - fi - - name: Verify older pinned revisions - if: steps.identity.outputs.changed == 'true' - shell: bash - env: - MINIMUM_DAYS: ${{ inputs.minimum-days }} - run: | - set -euo pipefail - while IFS=$'\t' read -r repository revision occurrence; do - if [[ ${#revision} -eq 40 ]]; then - case "$revision" in - *[!0-9a-f]*) exit 1 ;; - esac - tags=() - while read -r commit ref; do - if [[ "$commit" == "$revision" ]]; then - tag=${ref#refs/tags/} - tags+=("${tag%"^{}"}") - fi - done < <(git ls-remote --tags "$repository") - options=(--freeze) - else - tags=("$revision") - options=() - fi - accepted=false - for tag in "${tags[@]}"; do - cp "$RUNNER_TEMP/proposed-hooks.yaml" "$RUNNER_TEMP/candidate-hooks.yaml" - uv run --no-project --python "$RUNNER_TEMP/hook-tools" yq -y -i --arg repository "$repository" \ - "(.repos[] | select(.repo == \$repository) | .rev) = \"0000000000000000000000000000000000000000\"" \ - "$RUNNER_TEMP/candidate-hooks.yaml" - uv run --no-project --python "$RUNNER_TEMP/hook-tools" prek update -C "$RUNNER_TEMP/hook-cooldown" \ - --config "$RUNNER_TEMP/candidate-hooks.yaml" --repo "$repository" \ - --repo-include-tag "$repository=$tag" --cooldown-days "$MINIMUM_DAYS" \ - "${options[@]}" --no-progress - selected_revisions=$(uv run --no-project --python "$RUNNER_TEMP/hook-tools" yq -r \ - --arg repository "$repository" \ - ".repos[] | select(.repo == \$repository) | .rev" "$RUNNER_TEMP/candidate-hooks.yaml") - mapfile -t selected <<<"$selected_revisions" - if [[ "${selected[$occurrence]:-}" == "$revision" ]]; then - accepted=true - break - fi - done - if [[ "$accepted" != true ]]; then - echo "::error::hook revision is not eligible: $repository $revision" >&2 - exit 1 - fi - done <"$RUNNER_TEMP/older-hook-revisions" diff --git a/recheck-hook-update-cooldown/action.yml b/recheck-hook-update-cooldown/action.yml deleted file mode 100644 index 5bd8870..0000000 --- a/recheck-hook-update-cooldown/action.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Recheck cooled hook updates -description: Recheck failed hook-update cooldown jobs each day -inputs: - workflow: - description: Workflow file containing the hook cooldown job - default: check.yaml - job-name: - description: Name of the hook cooldown job - default: hook-cooldown -runs: - using: composite - steps: - - name: Find hook update pull requests - shell: bash - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh api --paginate "repos/$GITHUB_REPOSITORY/pulls?state=open&per_page=100" | - jq -c --arg repository "$GITHUB_REPOSITORY" ' - .[] | select(.head.repo.full_name != null and .base.repo.full_name == $repository) | - {number, head: .head.sha, branch: .head.ref, headRepository: .head.repo.full_name, opened: .created_at} - ' >"$RUNNER_TEMP/hook-pulls.jsonl" - - name: Rerun the failed hook checks - shell: bash - env: - GH_TOKEN: ${{ github.token }} - WORKFLOW: ${{ inputs.workflow }} - JOB_NAME: ${{ inputs.job-name }} - run: | - set -euo pipefail - [[ -n "$WORKFLOW" && -n "$JOB_NAME" ]] || exit 1 - while IFS= read -r pull; do - number=$(jq -r .number <<<"$pull") - gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$number/files" --jq '.[].filename' \ - >"$RUNNER_TEMP/hook-files" - if ! grep -Fxq .pre-commit-config.yaml "$RUNNER_TEMP/hook-files"; then - continue - fi - head=$(jq -r .head <<<"$pull") - branch=$(jq -r .branch <<<"$pull") - head_repository=$(jq -r .headRepository <<<"$pull") - opened=$(jq -r '.opened | fromdateiso8601' <<<"$pull") - # The check also accepts revisions observed on earlier heads, so maturity counts every head of the branch. - runs=$(gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/workflows/$WORKFLOW/runs" \ - -f event=pull_request -f branch="$branch" -f per_page=100 | - jq -sc --arg repository "$head_repository" --arg branch "$branch" \ - --argjson number "$number" --argjson opened "$opened" ' - [.[].workflow_runs[] | select( - .event == "pull_request" and .head_repository.full_name == $repository and - .head_branch == $branch and - (any(.pull_requests[]; .number == $number) or - (.pull_requests == [] and (.created_at | fromdateiso8601) >= $opened)) - )]') - if ! jq -e \ - '[.[].created_at | fromdateiso8601] | min <= now - 7 * 86400' \ - <<<"$runs" >/dev/null; then - continue - fi - run=$(jq -c --arg head "$head" ' - [.[] | select(.head_sha == $head and .status == "completed")] | sort_by(.created_at) | last // empty - ' <<<"$runs") - if [[ -z "$run" ]]; then - continue - fi - if ! jq -e '(.created_at | fromdateiso8601) >= now - 30 * 86400' <<<"$run" >/dev/null; then - continue - fi - job=$(gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/runs/$(jq -r .id <<<"$run")/jobs" \ - -f filter=latest -f per_page=100 | - jq -sc --arg job_name "$JOB_NAME" \ - '[.[].jobs[] | select(.name == $job_name and .conclusion == "failure")] | first // empty') - if [[ -n "$job" ]]; then - if ! gh run rerun "$(jq -r .id <<<"$run")" --job "$(jq -r .id <<<"$job")" \ - --repo "$GITHUB_REPOSITORY"; then - echo "::warning::could not rerun hook cooldown for pull request #$number" >&2 - fi - fi - done <"$RUNNER_TEMP/hook-pulls.jsonl" diff --git a/tests/check_hook_update_cooldown/fixtures/pre-bootstrap.yaml b/tests/check_hook_update_cooldown/fixtures/pre-bootstrap.yaml deleted file mode 100644 index 0810075..0000000 --- a/tests/check_hook_update_cooldown/fixtures/pre-bootstrap.yaml +++ /dev/null @@ -1,40 +0,0 @@ -default_language_version: - python: python3.15 -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0 - hooks: - - id: end-of-file-fixer - - id: trailing-whitespace - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 38bfb07c0c64c304f47eb52090f8288150c77730 # v0.17.1 - hooks: - - id: ruff-check - args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"] - - id: ruff-format - types_or: [python, pyi] - - repo: local - hooks: - - id: mdformat - name: mdformat - entry: mdformat --wrap 120 - language: python - types: [markdown] - additional_dependencies: [mdformat-gfm==1.0.0] - - repo: https://github.com/scop/pre-commit-shfmt - rev: 479be5958357ba5ab65ce47172117348516860e9 # v3.14.1-1 - hooks: - - id: shfmt - args: ["-i", "2", "-d"] - - repo: https://github.com/shellcheck-py/shellcheck-py - rev: 745eface02aef23e168a8afb6b5737818efbea95 # v0.11.0.1 - hooks: - - id: shellcheck - - repo: https://github.com/google/yamlfmt - rev: 21ca5323a9c87ee37a434e0ca908efc0a89daa07 # v0.21.0 - hooks: - - id: yamlfmt - - repo: https://github.com/zizmorcore/zizmor-pre-commit - rev: c92afdf2c6ca336aaa8f39e2bd8d48f62dbd8e1f # v1.31.1 - hooks: - - id: zizmor diff --git a/tests/check_hook_update_cooldown/test_cooldown.py b/tests/check_hook_update_cooldown/test_cooldown.py deleted file mode 100644 index 3534fe6..0000000 --- a/tests/check_hook_update_cooldown/test_cooldown.py +++ /dev/null @@ -1,933 +0,0 @@ -from __future__ import annotations - -import base64 -import json -import os -import subprocess -from datetime import UTC, datetime, timedelta -from pathlib import Path -from typing import Literal - -import pytest -from conftest import action -from pytest_httpserver import HTTPServer - - -@pytest.mark.parametrize( - ("author", "revision", "frozen_base", "accepted"), - [ - pytest.param("gaborbernat", "v1", False, True, id="human-cooled-revision"), - pytest.param("gaborbernat", "v2", False, False, id="human-young-revision"), - pytest.param("pre-commit-ci[bot]", "v1", False, True, id="cooled-revision-on-new-head"), - pytest.param("pre-commit-ci[bot]", "v0.5", False, True, id="older-cooled-revision"), - pytest.param("pre-commit-ci[bot]", "1.0", False, True, id="cooled-alias"), - pytest.param("pre-commit-ci[bot]", "sha-v0.5", True, True, id="older-frozen-revision"), - pytest.param("pre-commit-ci[bot]", "v1", True, True, id="frozen-base"), - pytest.param("pre-commit-ci[bot]", "v2", False, False, id="young-revision-on-old-pr"), - pytest.param("release-app[bot]", "v1", False, True, id="app-cooled-revision"), - pytest.param("release-app[bot]", "v2", False, False, id="app-young-revision"), - ], -) -def test_cooldown_checks_hook_revision( - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - author: str, - revision: str, - frozen_base: bool, - accepted: bool, -) -> None: - server, environment = github_api - repository = hook_repository(tmp_path) - expect_pull(server, author=author) - base_revision = ( - subprocess.run( - ["git", "-C", str(repository), "rev-parse", "v0^{}"], - text=True, - capture_output=True, - check=True, - ).stdout.strip() - if frozen_base - else "v0" - ) - if revision == "sha-v0.5": - revision = subprocess.run( - ["git", "-C", str(repository), "rev-parse", "v0.5^{}"], - text=True, - capture_output=True, - check=True, - ).stdout.strip() - expect_config(server, "b" * 40, repository, base_revision) - expect_config(server, "a" * 40, repository, revision) - result = run_cooldown(action_scripts, tmp_path, environment) - assert (result.returncode == 0) is accepted, result.stderr - assert [request.path for request, _ in server.log] == [ - "/api/v3/repos/tox-dev/fixture/pulls/7", - "/api/v3/repos/tox-dev/fixture/pulls/7/files", - "/api/v3/repos/tox-dev/fixture/actions/runs", - "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml", - "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml", - ] - - -@pytest.mark.parametrize( - ("observed_days", "accepted"), - [pytest.param(10, True, id="cooled"), pytest.param(0, False, id="new")], -) -def test_cooldown_checks_new_human_hook_repository( - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - observed_days: int, - accepted: bool, -) -> None: - server, environment = github_api - repository = hook_repository(tmp_path) - expect_pull(server, author="gaborbernat", observed_days=observed_days) - server.expect_request( - "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml", - method="GET", - query_string=f"ref={'b' * 40}", - ).respond_with_json({"content": base64.b64encode(b"repos: []\n").decode()}) - expect_config(server, "a" * 40, repository, "v1", hook=" - id: sample\n args: [--strict]\n") - if not accepted: - expect_historical_runs(server, older_head=None) - - result = run_cooldown(action_scripts, tmp_path, environment) - - assert (result.returncode == 0) is accepted, result.stderr - if not accepted: - assert "hook revision has not been observed" in result.stderr - - -def test_cooldown_checks_fork_head_configuration( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - expect_pull(server, author="gaborbernat", head_repository="gaborbernat/fixture") - for repository, sha, content in ( - ("tox-dev/fixture", "b" * 40, "repos: []\n"), - ( - "gaborbernat/fixture", - "a" * 40, - ( - "repos:\n - repo: local\n hooks:\n - id: check\n name: check\n" - " entry: true\n language: system\n" - ), - ), - ): - server.expect_request( - f"/api/v3/repos/{repository}/contents/.pre-commit-config.yaml", method="GET", query_string=f"ref={sha}" - ).respond_with_json({"content": base64.b64encode(content.encode()).decode()}) - - result = run_cooldown(action_scripts, tmp_path, environment) - - assert result.returncode == 0, result.stderr - assert [request.path for request, _ in server.log if request.path.endswith("/.pre-commit-config.yaml")] == [ - "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml", - "/api/v3/repos/gaborbernat/fixture/contents/.pre-commit-config.yaml", - ] - - -@pytest.mark.parametrize( - ("head_repository", "observed_repository", "opened_days", "observed_days", "workflow_path", "accepted"), - [ - pytest.param( - "gaborbernat/fixture", None, 20, 10, ".github/workflows/check.yaml", True, id="fork-observed-after-open" - ), - pytest.param( - "gaborbernat/fixture", None, 20, 0, ".github/workflows/check.yaml", False, id="fork-observed-too-recently" - ), - pytest.param( - "gaborbernat/fixture", None, 5, 10, ".github/workflows/check.yaml", False, id="old-run-before-new-pr" - ), - pytest.param( - "tox-dev/fixture", None, 20, 10, ".github/workflows/check.yaml", True, id="same-repository-unlinked-run" - ), - pytest.param( - "gaborbernat/fixture", - "another/fixture", - 20, - 10, - ".github/workflows/check.yaml", - False, - id="same-branch-other-fork", - ), - pytest.param( - "gaborbernat/fixture", None, 20, 10, ".github/workflows/news.yaml", False, id="unrelated-workflow" - ), - ], -) -def test_cooldown_checks_unlinked_run_identity( - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - head_repository: str, - observed_repository: str | None, - opened_days: int, - observed_days: int, - workflow_path: str, - accepted: bool, -) -> None: - server, environment = github_api - repository = hook_repository(tmp_path) - expect_pull( - server, - author="gaborbernat", - head_repository=head_repository, - opened_days=opened_days, - observed_days=observed_days, - associated_pull_request=None, - observed_repository=observed_repository, - workflow_path=workflow_path, - ) - expect_config(server, "b" * 40, repository, "v0") - expect_config(server, "a" * 40, repository, "v1", source_repository=head_repository) - if not accepted: - expect_historical_runs(server, older_head=None) - - result = run_cooldown(action_scripts, tmp_path, environment) - - assert (result.returncode == 0) is accepted, result.stderr - if not accepted: - assert "hook revision has not been observed" in result.stderr - - -@pytest.mark.parametrize( - ("head_repository", "opened_days", "observed_repository", "workflow_path", "accepted"), - [ - pytest.param( - "gaborbernat/fixture", 20, "gaborbernat/fixture", ".github/workflows/check.yaml", True, id="fork-history" - ), - pytest.param( - "tox-dev/fixture", 20, "tox-dev/fixture", ".github/workflows/check.yaml", True, id="same-repository-history" - ), - pytest.param( - "gaborbernat/fixture", - 5, - "gaborbernat/fixture", - ".github/workflows/check.yaml", - False, - id="earlier-pr-same-branch", - ), - pytest.param( - "gaborbernat/fixture", - 20, - "another/fixture", - ".github/workflows/check.yaml", - False, - id="same-branch-other-fork", - ), - pytest.param( - "gaborbernat/fixture", - 20, - "gaborbernat/fixture", - ".github/workflows/news.yaml", - False, - id="unrelated-workflow", - ), - ], -) -def test_cooldown_checks_unlinked_history( - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - head_repository: str, - opened_days: int, - observed_repository: str, - workflow_path: str, - accepted: bool, -) -> None: - server, environment = github_api - repository = hook_repository(tmp_path) - expect_pull( - server, - author="gaborbernat", - head_repository=head_repository, - opened_days=opened_days, - observed_days=0, - associated_pull_request=None, - ) - expect_config(server, "b" * 40, repository, "v0") - expect_config(server, "a" * 40, repository, "v1", source_repository=head_repository) - expect_historical_runs( - server, - older_head="c" * 40, - head_repository=observed_repository, - pull_request=None, - workflow_path=workflow_path, - ) - if accepted: - expect_config(server, "c" * 40, repository, "v1", source_repository=head_repository) - - result = run_cooldown(action_scripts, tmp_path, environment) - - assert (result.returncode == 0) is accepted, result.stderr - if not accepted: - assert "hook revision has not been observed" in result.stderr - - -@pytest.mark.parametrize( - ("minimum_days", "accepted"), - [pytest.param("7", True, id="default"), pytest.param("1000", False, id="configured-cooldown")], -) -def test_cooldown_checks_human_local_hook_dependencies( - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - minimum_days: str, - accepted: bool, -) -> None: - server, environment = github_api - expect_pull(server, author="gaborbernat", observed_days=1100) - server.expect_request( - "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml", - method="GET", - query_string=f"ref={'b' * 40}", - ).respond_with_json({"content": base64.b64encode(b"repos: []\n").decode()}) - proposed = ( - "repos:\n - repo: local\n hooks:\n - id: check\n name: check\n" - " entry: python\n language: python\n" - " additional_dependencies:\n - packaging==25.0\n" - ) - server.expect_request( - "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml", - method="GET", - query_string=f"ref={'a' * 40}", - ).respond_with_json({"content": base64.b64encode(proposed.encode()).decode()}) - - result = run_cooldown(action_scripts, tmp_path, environment, minimum_days=minimum_days) - - assert (result.returncode == 0) is accepted, result.stderr - assert (tmp_path / "hook-closure/.pre-commit-config.yaml").read_text() == proposed - - -@pytest.mark.parametrize( - "path", - [ - pytest.param("prek.toml", id="root-prek-toml"), - pytest.param("sub/prek.toml", id="nested-prek-toml"), - pytest.param(".pre-commit-config.yml", id="root-yml"), - pytest.param("sub/.pre-commit-config.yaml", id="nested-yaml"), - pytest.param("sub/.pre-commit-config.yml", id="nested-yml"), - ], -) -def test_cooldown_rejects_other_hook_configuration( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], path: str -) -> None: - server, environment = github_api - expect_pull(server, author="gaborbernat", hook_update=False, other_files=(("added", path),)) - - result = run_cooldown(action_scripts, tmp_path, environment) - - assert result.returncode != 0 - assert f"only in the root .pre-commit-config.yaml: {path}" in result.stderr - - -def test_cooldown_allows_removing_other_hook_configuration( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - expect_pull(server, author="gaborbernat", hook_update=False, other_files=(("removed", "prek.toml"),)) - - result = run_cooldown(action_scripts, tmp_path, environment) - - assert result.returncode == 0, result.stderr - - -def test_cooldown_skips_bot_without_hook_changes( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - expect_pull(server, author="dependabot[bot]", hook_update=False) - result = run_cooldown(action_scripts, tmp_path, environment) - assert result.returncode == 0, result.stderr - assert [request.path for request, _ in server.log] == [ - "/api/v3/repos/tox-dev/fixture/pulls/7", - "/api/v3/repos/tox-dev/fixture/pulls/7/files", - ] - - -def test_cooldown_skips_unrelated_human_fork( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - expect_pull(server, author="gaborbernat", head_repository="gaborbernat/fixture", hook_update=False) - - result = run_cooldown(action_scripts, tmp_path, environment) - - assert result.returncode == 0, result.stderr - assert [request.path for request, _ in server.log] == [ - "/api/v3/repos/tox-dev/fixture/pulls/7", - "/api/v3/repos/tox-dev/fixture/pulls/7/files", - ] - - -@pytest.mark.parametrize( - "dependency_location", - [pytest.param("project", id="project"), pytest.param("manifest", id="manifest")], -) -def test_cooldown_rejects_rev_only_hook_with_new_dependency( - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - dependency_location: Literal["project", "manifest"], -) -> None: - server, environment = github_api - repository = hook_repository(tmp_path, dependency_location=dependency_location) - expect_pull(server, author="pre-commit-ci[bot]") - for ref, tag in (("b" * 40, "v0"), ("a" * 40, "v1")): - revision = subprocess.run( - ["git", "-C", str(repository), "rev-parse", f"{tag}^{{}}"], - text=True, - capture_output=True, - check=True, - ).stdout.strip() - expect_config(server, ref, repository, revision) - - result = run_cooldown(action_scripts, tmp_path, environment) - - assert result.returncode != 0 - assert "hook is missing managed dependencies" in result.stderr - - -@pytest.mark.parametrize( - ("observed_days", "accepted"), - [pytest.param(0, False, id="new"), pytest.param(10, True, id="cooled")], -) -def test_cooldown_checks_generated_update_branch( - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - observed_days: int, - accepted: bool, -) -> None: - server, environment = github_api - repository = hook_repository(tmp_path) - expect_pull( - server, author="release-app[bot]", branch="automation/update-release-tools", observed_days=observed_days - ) - for ref, tag in (("b" * 40, "v0"), ("a" * 40, "v1")): - revision = subprocess.run( - ["git", "-C", str(repository), "rev-parse", f"{tag}^{{}}"], - text=True, - capture_output=True, - check=True, - ).stdout.strip() - expect_config(server, ref, repository, revision) - if not accepted: - expect_historical_runs(server, older_head=None) - - result = run_cooldown(action_scripts, tmp_path, environment | {"RELEASE_APP_CLIENT_ID": "Iv1.release"}) - - assert (result.returncode == 0) is accepted, result.stderr - assert len(server.log) >= 4 - - -@pytest.mark.parametrize( - "marker", - [ - pytest.param('python_version >= "3.15"', id="active"), - pytest.param('python_version < "3.15"', id="inactive"), - ], -) -def test_cooldown_accepts_managed_pin_with_python_marker( - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - marker: str, -) -> None: - server, environment = github_api - source = ( - "repos:\n - repo: local\n hooks:\n - id: lint\n name: lint\n" - " entry: echo\n language: python\n" - " additional_dependencies:\n" - f" - 'packaging>=24,<25; {marker}'\n" - f" - 'packaging==24.2; {marker}' # release-creator managed\n" - ) - expect_pull(server, author="release-app[bot]", branch="automation/update-release-tools") - for ref in ("b" * 40, "a" * 40): - server.expect_request( - "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml", method="GET", query_string=f"ref={ref}" - ).respond_with_json({"content": base64.b64encode(source.encode()).decode()}) - - result = run_cooldown(action_scripts, tmp_path, environment | {"RELEASE_APP_CLIENT_ID": "Iv1.release"}) - - assert result.returncode == 0, result.stderr - assert (tmp_path / "hook-closure/.pre-commit-config.yaml").read_text() == source - - -@pytest.mark.parametrize( - ("author", "client_id"), - [ - pytest.param("gaborbernat", "Iv1.release", id="human-author"), - pytest.param("release-app[bot]", "", id="unconfigured-app"), - ], -) -def test_cooldown_requires_configured_app_on_updater_branch( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], author: str, client_id: str -) -> None: - server, environment = github_api - expect_pull(server, author=author, branch="automation/update-release-tools") - - result = run_cooldown(action_scripts, tmp_path, environment | {"RELEASE_APP_CLIENT_ID": client_id}) - - assert result.returncode != 0 - assert "release tool branch requires the configured release App" in result.stderr - assert "/api/v3/apps/release-app" not in [request.path for request, _ in server.log] - - -@pytest.mark.parametrize( - ("client_id", "head_repository"), - [ - pytest.param("Iv1.other", "tox-dev/fixture", id="another-app"), - pytest.param("Iv1.release", "other/fixture", id="fork-head"), - ], -) -def test_cooldown_rejects_untrusted_updater_identity( - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - client_id: str, - head_repository: str, -) -> None: - server, environment = github_api - expect_pull( - server, - author="release-app[bot]", - branch="automation/update-release-tools", - app_client_id=client_id, - head_repository=head_repository, - ) - - result = run_cooldown(action_scripts, tmp_path, environment | {"RELEASE_APP_CLIENT_ID": "Iv1.release"}) - - assert result.returncode != 0 - assert ( - "different App identity" in result.stderr - if head_repository == "tox-dev/fixture" - else [request.path for request, _ in server.log] - == ["/api/v3/repos/tox-dev/fixture/pulls/7", "/api/v3/repos/tox-dev/fixture/pulls/7/files"] - ) - - -def test_cooldown_rejects_hook_configuration_change( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - repository = hook_repository(tmp_path) - expect_pull(server, author="pre-commit-ci[bot]") - expect_config(server, "b" * 40, repository, "v0") - expect_config(server, "a" * 40, repository, "v1", hook=" - id: sample\n entry: untrusted\n") - - result = run_cooldown(action_scripts, tmp_path, environment) - - assert result.returncode != 0 - assert "changes fields beyond revisions" in result.stderr - - -def test_cooldown_rejects_changed_head( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - expect_pull(server, author="pre-commit-ci[bot]", head="c" * 40) - result = run_cooldown(action_scripts, tmp_path, environment) - assert result.returncode != 0 - assert len(server.log) == 1 - - -@pytest.mark.parametrize( - "minimum_days", - [ - pytest.param("6", id="short"), - pytest.param("07", id="leading-zero"), - pytest.param("bad", id="non-numeric"), - ], -) -def test_cooldown_rejects_invalid_minimum( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], minimum_days: str -) -> None: - server, environment = github_api - result = run_cooldown(action_scripts, tmp_path, environment, minimum_days=minimum_days) - assert result.returncode != 0 - assert "minimum-days must be a whole number of at least seven" in result.stderr - assert not server.log - - -def test_cooldown_rejects_bot_without_observed_run( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - repository = hook_repository(tmp_path) - expect_pull(server, author="pre-commit-ci[bot]", observed_days=None) - expect_config(server, "b" * 40, repository, "v0") - expect_config(server, "a" * 40, repository, "v1") - expect_historical_runs(server, older_head=None) - result = run_cooldown(action_scripts, tmp_path, environment) - assert result.returncode != 0 - assert "hook revision has not been observed" in result.stderr - - -def test_cooldown_rejects_backdated_tag_on_recent_pull_request( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - expect_pull(server, author="pre-commit-ci[bot]", observed_days=0, updated_days=10) - repository = hook_repository(tmp_path) - expect_config(server, "b" * 40, repository, "v0") - expect_config(server, "a" * 40, repository, "v2") - expect_historical_runs(server, older_head=None) - result = run_cooldown(action_scripts, tmp_path, environment) - assert result.returncode != 0 - assert "hook revision has not been observed" in result.stderr - - -@pytest.mark.parametrize( - ("historical_revision", "accepted"), - [pytest.param("v1", True, id="same-revision"), pytest.param("v0", False, id="new-revision")], -) -def test_cooldown_survives_force_push_without_accepting_new_revision( - tmp_path: Path, - action_scripts: Path, - github_api: tuple[HTTPServer, dict[str, str]], - historical_revision: str, - accepted: bool, -) -> None: - server, environment = github_api - repository = hook_repository(tmp_path) - expect_pull(server, author="pre-commit-ci[bot]", observed_days=0) - expect_config(server, "b" * 40, repository, "v0") - expect_config(server, "a" * 40, repository, "v1") - expect_historical_runs(server, older_head="c" * 40) - expect_config(server, "c" * 40, repository, historical_revision) - result = run_cooldown(action_scripts, tmp_path, environment) - assert (result.returncode == 0) is accepted, result.stderr - branch_runs = [ - request for request, _ in server.log if request.path.endswith("/actions/runs") and "branch" in request.args - ] - assert len(branch_runs) == 1 - cutoff = datetime.fromisoformat(branch_runs[0].args["created"].removeprefix("<")) - assert timedelta(days=6, hours=23) < datetime.now(UTC) - cutoff < timedelta(days=7, hours=1) - if not accepted: - assert "hook revision has not been observed" in result.stderr - - -def test_cooldown_skips_unavailable_old_head( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - repository = hook_repository(tmp_path) - expect_pull(server, author="pre-commit-ci[bot]", observed_days=0) - expect_config(server, "b" * 40, repository, "v0") - expect_config(server, "a" * 40, repository, "v1") - expect_historical_runs(server, older_head=("c" * 40, "d" * 40)) - server.expect_request( - "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml", - method="GET", - query_string=f"ref={'c' * 40}", - ).respond_with_json({"message": "Not Found"}, status=404) - expect_config(server, "d" * 40, repository, "v1") - result = run_cooldown(action_scripts, tmp_path, environment) - assert result.returncode == 0, result.stderr - - -def test_cooldown_skips_malformed_old_head( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - repository = hook_repository(tmp_path) - expect_pull(server, author="pre-commit-ci[bot]", observed_days=0) - expect_config(server, "b" * 40, repository, "v0") - expect_config(server, "a" * 40, repository, "v1") - expect_historical_runs(server, older_head=("c" * 40, "d" * 40)) - server.expect_request( - "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml", - method="GET", - query_string=f"ref={'c' * 40}", - ).respond_with_json({"content": base64.b64encode(b"repos: [").decode()}) - expect_config(server, "d" * 40, repository, "v1") - result = run_cooldown(action_scripts, tmp_path, environment) - assert result.returncode == 0, result.stderr - - -def test_cooldown_matches_revision_after_repository_list_change( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - repository = hook_repository(tmp_path) - expect_pull(server, author="pre-commit-ci[bot]", observed_days=0) - expect_config(server, "b" * 40, repository, "v0") - expect_config(server, "a" * 40, repository, "v1") - expect_historical_runs(server, older_head="c" * 40) - prior_revision = subprocess.run( - ["git", "-C", str(repository), "rev-parse", "v1^{}"], - text=True, - capture_output=True, - check=True, - ).stdout.strip() - historical = ( - "repos:\n" - " - repo: https://example.invalid/another-hook\n rev: v2\n hooks: []\n" - f" - repo: {repository.as_uri()}\n rev: {prior_revision}\n hooks:\n - id: sample\n" - ) - server.expect_request( - "/api/v3/repos/tox-dev/fixture/contents/.pre-commit-config.yaml", - method="GET", - query_string=f"ref={'c' * 40}", - ).respond_with_json({"content": base64.b64encode(historical.encode()).decode()}) - result = run_cooldown(action_scripts, tmp_path, environment) - assert result.returncode == 0, result.stderr - - -def test_cooldown_ignores_another_pull_request_history( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - repository = hook_repository(tmp_path) - expect_pull(server, author="pre-commit-ci[bot]", observed_days=0) - expect_config(server, "b" * 40, repository, "v0") - expect_config(server, "a" * 40, repository, "v1") - expect_historical_runs(server, older_head="c" * 40, pull_request=8) - result = run_cooldown(action_scripts, tmp_path, environment) - assert result.returncode != 0 - assert "hook revision has not been observed" in result.stderr - - -def test_cooldown_checks_each_duplicate_repository( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - repository = hook_repository(tmp_path) - expect_pull(server, author="pre-commit-ci[bot]") - expect_config(server, "b" * 40, repository, ("v0", "v0")) - expect_config(server, "a" * 40, repository, ("v2", "v0")) - result = run_cooldown(action_scripts, tmp_path, environment) - assert result.returncode != 0 - assert "hook repository entries changed" not in result.stderr - assert "hook revision is not eligible" in result.stderr - - -def test_cooldown_does_not_use_another_duplicate_repository_observation( - tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - repository = hook_repository(tmp_path) - expect_pull(server, author="pre-commit-ci[bot]", observed_days=0) - expect_config(server, "b" * 40, repository, ("v0", "v0")) - expect_config(server, "a" * 40, repository, ("v1", "v0")) - expect_historical_runs(server, older_head="c" * 40) - expect_config(server, "c" * 40, repository, ("v0", "v1")) - result = run_cooldown(action_scripts, tmp_path, environment) - assert result.returncode != 0 - assert "hook revision has not been observed" in result.stderr - - -def run_cooldown( - action_scripts: Path, - tmp_path: Path, - environment: dict[str, str], - *, - event_name: str = "pull_request", - event_head: str = "a" * 40, - minimum_days: str = "7", -) -> subprocess.CompletedProcess[str]: - path = tmp_path / "event.json" - path.write_text(json.dumps({"number": 7, "pull_request": {"head": {"sha": event_head}}})) - (tmp_path / "pylock.toml").write_text( - 'lock-version = "1.0"\ncreated-by = "test"\nrequires-python = ">=3.15"\npackages = []\n', - ) - return action( - tmp_path, - tmp_path, - action_scripts, - "check-hook-update-cooldown", - version="", - MINIMUM_DAYS=minimum_days, - GITHUB_EVENT_NAME=event_name, - GITHUB_EVENT_PATH=str(path), - GITHUB_OUTPUT=str(tmp_path / "outputs"), - GITHUB_WORKFLOW_REF="tox-dev/fixture/.github/workflows/check.yaml@refs/pull/7/merge", - **environment, - ) - - -def expect_pull( - server: HTTPServer, - *, - author: str, - head: str = "a" * 40, - observed_days: int | None = 10, - updated_days: int = 0, - hook_update: bool = True, - branch: str = "bot-update", - app_client_id: str = "Iv1.release", - head_repository: str = "tox-dev/fixture", - opened_days: int = 20, - associated_pull_request: int | None = 7, - observed_repository: str | None = None, - workflow_path: str = ".github/workflows/check.yaml", - other_files: tuple[tuple[str, str], ...] = (), -) -> None: - server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7", method="GET").respond_with_json( - { - "state": "open", - "base": {"repo": {"full_name": "tox-dev/fixture"}, "sha": "b" * 40}, - "head": {"sha": head, "ref": branch, "repo": {"full_name": head_repository}}, - "user": {"login": author, "type": "Bot" if author.endswith("[bot]") else "User"}, - "created_at": (datetime.now(UTC) - timedelta(days=opened_days)).strftime("%Y-%m-%dT%H:%M:%SZ"), - "updated_at": (datetime.now(UTC) - timedelta(days=updated_days)).strftime("%Y-%m-%dT%H:%M:%SZ"), - } - ) - server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7/files", method="GET").respond_with_json( - [ - {"status": "modified", "filename": ".pre-commit-config.yaml" if hook_update else "pyproject.toml"}, - *({"status": status, "filename": filename} for status, filename in other_files), - ] - ) - if hook_update: - if author.endswith("[bot]") and branch == "automation/update-release-tools": - server.expect_request("/api/v3/apps/release-app", method="GET").respond_with_json( - {"slug": "release-app", "client_id": app_client_id} - ) - server.expect_request( - "/api/v3/repos/tox-dev/fixture/actions/runs", - method="GET", - query_string=f"event=pull_request&head_sha={head}&per_page=100", - ).respond_with_json( - { - "workflow_runs": [] - if observed_days is None - else [ - { - "event": "pull_request", - "head_repository": {"full_name": observed_repository or head_repository}, - "head_branch": branch, - "head_sha": head, - "pull_requests": [] - if associated_pull_request is None - else [{"number": associated_pull_request}], - "path": workflow_path, - "created_at": (datetime.now(UTC) - timedelta(days=observed_days)).strftime( - "%Y-%m-%dT%H:%M:%SZ" - ), - } - ] - } - ) - - -def expect_config( - server: HTTPServer, - sha: str, - repository: Path, - revision: str | tuple[str, ...], - *, - hook: str = " - id: sample\n", - source_repository: str = "tox-dev/fixture", -) -> None: - revisions = (revision,) if isinstance(revision, str) else revision - frozen = tuple( - item - if len(item) == 40 - else subprocess.run( - ["git", "-C", str(repository), "rev-parse", f"{item}^{{}}"], - text=True, - capture_output=True, - check=True, - ).stdout.strip() - for item in revisions - ) - server.expect_request( - f"/api/v3/repos/{source_repository}/contents/.pre-commit-config.yaml", - method="GET", - query_string=f"ref={sha}", - ).respond_with_json( - { - "content": base64.b64encode( - ( - "repos:\n" - + "".join( - f" - repo: {repository.as_uri()}\n rev: {item}\n hooks:\n{hook}" for item in frozen - ) - ).encode() - ).decode() - } - ) - - -def expect_historical_runs( - server: HTTPServer, - *, - older_head: str | tuple[str, ...] | None, - pull_request: int | None = 7, - head_repository: str = "tox-dev/fixture", - workflow_path: str = ".github/workflows/check.yaml", -) -> None: - heads = () if older_head is None else (older_head,) if isinstance(older_head, str) else older_head - server.expect_request( - "/api/v3/repos/tox-dev/fixture/actions/runs", - method="GET", - ).respond_with_json( - { - "workflow_runs": [ - { - "event": "pull_request", - "head_repository": {"full_name": head_repository}, - "head_branch": "bot-update", - "head_sha": head, - "pull_requests": [] if pull_request is None else [{"number": pull_request}], - "path": workflow_path, - "created_at": (datetime.now(UTC) - timedelta(days=10)).strftime("%Y-%m-%dT%H:%M:%SZ"), - } - for head in heads - ] - } - ) - - -def hook_repository(tmp_path: Path, *, dependency_location: Literal["project", "manifest"] | None = None) -> Path: - repository = tmp_path / "hook-repo" - repository.mkdir() - subprocess.run(["git", "init", "-q", str(repository)], check=True) - subprocess.run(["git", "-C", str(repository), "config", "user.name", "Hook test"], check=True) - subprocess.run(["git", "-C", str(repository), "config", "user.email", "hooks@example.org"], check=True) - (repository / ".pre-commit-hooks.yaml").write_text( - f"- id: sample\n name: sample\n entry: true\n language: {'python' if dependency_location else 'system'}\n" - ) - if dependency_location: - (repository / "pyproject.toml").write_text( - '[build-system]\nrequires = ["setuptools==80.9.0"]\nbuild-backend = "setuptools.build_meta"\n' - '[project]\nname = "hook-fixture"\nversion = "0.1.0"\ndependencies = []\n' - ) - package = repository / "src/hook_fixture" - package.mkdir(parents=True) - (package / "__init__.py").write_text('"""Hook fixture."""\n') - subprocess.run(["git", "-C", str(repository), "add", "."], check=True) - subprocess.run(["git", "-C", str(repository), "commit", "-qm", "Add hook"], check=True) - for name, days in (("v0", 20), ("v0.5", 15), ("v1", 10), ("v2", 1)): - date = (datetime.now(UTC) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ") - (repository / "version").write_text(name) - if dependency_location == "project" and name == "v1": - project = repository / "pyproject.toml" - project.write_text(project.read_text().replace("dependencies = []", 'dependencies = ["packaging==24.2"]')) - if dependency_location == "manifest" and name == "v1": - manifest = repository / ".pre-commit-hooks.yaml" - manifest.write_text(f"{manifest.read_text()} additional_dependencies: [packaging==24.2]\n") - subprocess.run(["git", "-C", str(repository), "add", "."], check=True) - subprocess.run( - ["git", "-C", str(repository), "commit", "-qm", name], - env=os.environ | {"GIT_AUTHOR_DATE": date, "GIT_COMMITTER_DATE": date}, - check=True, - ) - subprocess.run( - ["git", "-C", str(repository), "tag", "-a", name, "-m", name], - env=os.environ | {"GIT_COMMITTER_DATE": date}, - check=True, - ) - subprocess.run( - ["git", "-C", str(repository), "tag", "-a", "1.0", "v1^{}", "-m", "1.0"], - env=os.environ - | {"GIT_COMMITTER_DATE": (datetime.now(UTC) - timedelta(days=10)).strftime("%Y-%m-%dT%H:%M:%SZ")}, - check=True, - ) - return repository diff --git a/tests/check_hook_update_cooldown/test_hook_config.py b/tests/check_hook_update_cooldown/test_hook_config.py deleted file mode 100644 index ba82092..0000000 --- a/tests/check_hook_update_cooldown/test_hook_config.py +++ /dev/null @@ -1,570 +0,0 @@ -from __future__ import annotations - -import json -import runpy -from collections.abc import Callable -from datetime import UTC, datetime, timedelta -from io import BytesIO -from pathlib import Path -from typing import cast -from unittest.mock import MagicMock -from urllib.request import Request, urlopen - -import pytest -from pytest_mock import MockerFixture - - -@pytest.fixture -def pypi_file() -> dict[str, object]: - return { - "url": "https://files.pythonhosted.org/example-1.2.0.whl", - "digests": {"sha256": "a" * 64}, - "upload_time_iso_8601": (datetime.now(UTC) - timedelta(days=10)).isoformat(), - } - - -@pytest.fixture -def pypi_request(mocker: MockerFixture, pypi_file: dict[str, object]) -> MagicMock: - return cast("MagicMock", mocker.create_autospec(urlopen, return_value=_response("example", pypi_file))) - - -def _response(name: str, pypi_file: dict[str, object]) -> BytesIO: - return BytesIO(json.dumps({"info": {"name": name, "version": "1.2.0"}, "urls": [pypi_file]}).encode()) - - -@pytest.fixture -def check_config(mocker: MockerFixture, pypi_request: MagicMock) -> Callable[..., None]: - namespace = runpy.run_path( - str(Path(__file__).resolve().parents[2] / "tools/check_hook_config.py"), run_name="check_hook_config" - ) - mocker.patch.dict(namespace["check_config"].__globals__, {"urlopen": pypi_request}) - return cast("Callable[..., None]", namespace["check_config"]) - - -@pytest.fixture -def configs(tmp_path: Path) -> tuple[Path, Path]: - base = tmp_path / "base.yaml" - proposed = tmp_path / "proposed.yaml" - base.write_text( - "repos:\n - repo: https://example.org/hook\n rev: old\n hooks:\n" - " - id: sample\n additional_dependencies:\n - example==1.0.0\n" - ) - return base, proposed - - -def test_app_update_accepts_cooled_direct_pin( - configs: tuple[Path, Path], check_config: Callable[..., None], pypi_request: MagicMock -) -> None: - base, proposed = configs - proposed.write_text(base.read_text().replace("rev: old", "rev: new").replace("example==1.0.0", "example==1.2.0")) - - check_config(base, proposed, True, 7) - assert pypi_request.call_args.args[0].full_url == "https://pypi.org/pypi/example/1.2.0/json" - - -@pytest.mark.parametrize( - ("field", "value"), - [ - pytest.param("upload_time_iso_8601", "recent", id="recent"), - pytest.param("yanked", True, id="yanked"), - pytest.param("url", "https://example.org/wheel.whl", id="foreign-host"), - pytest.param("digests", {}, id="missing-hash"), - ], -) -def test_app_update_rejects_untrusted_pypi_file( - configs: tuple[Path, Path], - check_config: Callable[..., None], - pypi_file: dict[str, object], - pypi_request: MagicMock, - field: str, - value: object, -) -> None: - base, proposed = configs - proposed.write_text(base.read_text().replace("example==1.0.0", "example==1.2.0")) - pypi_file[field] = (datetime.now(UTC) - timedelta(days=1)).isoformat() if value == "recent" else value - pypi_request.return_value = _response("example", pypi_file) - - with pytest.raises(ValueError, match="uncooled or untrusted PyPI artifact"): - check_config(base, proposed, True, 7) - - -def test_app_update_accepts_cooled_managed_pin( - configs: tuple[Path, Path], check_config: Callable[..., None], pypi_file: dict[str, object], pypi_request: MagicMock -) -> None: - base, proposed = configs - proposed.write_text( - base.read_text().replace( - " - example==1.0.0\n", - ' - example==1.0.0\n - "other==1.2.0" # release-creator managed\n', - ) - ) - - pypi_request.return_value = _response("other", pypi_file) - check_config(base, proposed, True, 7) - assert pypi_request.call_args.args[0].full_url == "https://pypi.org/pypi/other/1.2.0/json" - - -def test_app_update_allows_removed_managed_pin_with_cooled_direct_change( - configs: tuple[Path, Path], check_config: Callable[..., None], pypi_request: MagicMock -) -> None: - base, proposed = configs - base.write_text( - base.read_text().replace( - " - example==1.0.0\n", - ' - example==1.0.0\n - "other==1.0.0" # release-creator managed\n', - ) - ) - proposed.write_text( - base.read_text() - .replace("example==1.0.0", "example==1.2.0") - .replace(' - "other==1.0.0" # release-creator managed\n', "") - ) - - check_config(base, proposed, True, 7) - - assert pypi_request.call_args.args[0].full_url == "https://pypi.org/pypi/example/1.2.0/json" - - -@pytest.mark.parametrize( - ("replacement", "message"), - [ - pytest.param("example>=1.2.0", "not an exact PyPI pin", id="range"), - pytest.param("other==1.2.0", "direct dependencies beyond an exact pin", id="another-package"), - pytest.param("example @ https://example.org/wheel.whl", "direct dependencies beyond an exact pin", id="url"), - ], -) -def test_app_update_rejects_unsafe_direct_change( - configs: tuple[Path, Path], check_config: Callable[..., None], replacement: str, message: str -) -> None: - base, proposed = configs - proposed.write_text(base.read_text().replace("example==1.0.0", replacement)) - - with pytest.raises(ValueError, match=message): - check_config(base, proposed, True, 7) - - -def test_other_bot_cannot_change_pin(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: - base, proposed = configs - proposed.write_text(base.read_text().replace("example==1.0.0", "example==1.2.0")) - - with pytest.raises(ValueError, match="beyond revisions"): - check_config(base, proposed, False, 7) - - -@pytest.mark.parametrize("human", [pytest.param(True, id="human"), pytest.param(False, id="bot")]) -def test_hook_config_allows_human_to_add_repository_and_options( - configs: tuple[Path, Path], check_config: Callable[..., None], human: bool -) -> None: - base, proposed = configs - proposed.write_text( - base.read_text() - + " - repo: https://example.org/another-hook\n rev: 0123456789012345678901234567890123456789\n" - + " hooks:\n - id: another\n args: [--strict]\n" - ) - - if human: - check_config(base, proposed, False, 7, human=True) - else: - with pytest.raises(ValueError, match="beyond revisions"): - check_config(base, proposed, False, 7) - - -def test_hook_config_rejects_duplicate_keys_in_human_change( - configs: tuple[Path, Path], check_config: Callable[..., None] -) -> None: - base, proposed = configs - proposed.write_text(base.read_text().replace(" rev: old\n", " rev: old\n rev: new\n")) - - with pytest.raises(ValueError, match="duplicate mapping keys"): - check_config(base, proposed, False, 7, human=True) - - -def test_hook_config_rejects_human_change_without_repositories( - configs: tuple[Path, Path], check_config: Callable[..., None] -) -> None: - base, proposed = configs - proposed.write_text("repos: null\n") - - with pytest.raises(TypeError, match="hook repositories must be lists"): - check_config(base, proposed, False, 7, human=True) - - -@pytest.mark.parametrize( - ("uploaded_days", "accepted"), - [pytest.param(10, True, id="cooled"), pytest.param(1, False, id="recent")], -) -def test_human_managed_pin_change_needs_cooled_pypi_pin( - configs: tuple[Path, Path], - check_config: Callable[..., None], - pypi_file: dict[str, object], - pypi_request: MagicMock, - uploaded_days: int, - accepted: bool, -) -> None: - base, proposed = configs - base.write_text( - base.read_text().replace( - " - example==1.0.0\n", ' - "example==1.0.0" # release-creator managed\n' - ) - ) - proposed.write_text(base.read_text().replace("example==1.0.0", "example==1.2.0")) - pypi_file["upload_time_iso_8601"] = (datetime.now(UTC) - timedelta(days=uploaded_days)).isoformat() - pypi_request.return_value = _response("example", pypi_file) - - if accepted: - check_config(base, proposed, False, 7, human=True) - else: - with pytest.raises(ValueError, match="uncooled or untrusted PyPI artifact"): - check_config(base, proposed, False, 7, human=True) - assert pypi_request.call_args.args[0].full_url == "https://pypi.org/pypi/example/1.2.0/json" - - -def test_human_can_add_python_hook_next_to_managed_pins( - configs: tuple[Path, Path], check_config: Callable[..., None], pypi_request: MagicMock -) -> None: - base, proposed = configs - prefix = "repos:\n - repo: https://example.org/hook\n rev: old\n hooks:\n" - first = ( - ' - id: first\n additional_dependencies:\n - "other==1.2.0" # release-creator managed\n' - ) - base.write_text(prefix + first) - proposed.write_text( - prefix - + first - + ' - id: second\n additional_dependencies:\n - "example==1.2.0" # release-creator managed\n' - ) - - check_config(base, proposed, False, 7, human=True) - - assert [call.args[0].full_url for call in pypi_request.call_args_list] == [ - "https://pypi.org/pypi/example/1.2.0/json" - ] - - -def test_human_keeps_existing_managed_pins_without_pypi( - configs: tuple[Path, Path], check_config: Callable[..., None], pypi_request: MagicMock -) -> None: - base, proposed = configs - prefix = "repos:\n - repo: https://example.org/hook\n rev: old\n hooks:\n" - base.write_text( - prefix - + " - id: first\n additional_dependencies:\n" - + ' - "other==1.2.0" # release-creator managed\n' - + " - id: second\n" - ) - proposed.write_text( - prefix - + " - id: second\n - id: first\n additional_dependencies:\n" - + ' - "other==1.2.0" # release-creator managed\n' - ) - - check_config(base, proposed, False, 7, human=True) - - pypi_request.assert_not_called() - - -@pytest.mark.parametrize( - "proposal", - [ - pytest.param( - "repos:\n - repo: local\n hooks:\n" - " - id: a\n name: new\n additional_dependencies:\n" - ' - "other==1.2.0" # release-creator managed\n' - " - id: a\n name: original\n", - id="duplicate-hook", - ), - pytest.param( - "repos:\n - repo: local\n hooks:\n" - " - id: a\n name: new\n additional_dependencies:\n" - ' - "other==1.2.0" # release-creator managed\n' - " - repo: local\n hooks:\n - id: a\n name: original\n", - id="duplicate-repository", - ), - ], -) -def test_human_cannot_relocate_managed_pin_through_duplicate_identity( - configs: tuple[Path, Path], check_config: Callable[..., None], proposal: str -) -> None: - base, proposed = configs - base.write_text( - "repos:\n - repo: local\n hooks:\n" - " - id: a\n name: original\n additional_dependencies:\n" - ' - "other==1.2.0" # release-creator managed\n' - ) - proposed.write_text(proposal) - - with pytest.raises(ValueError, match="managed hook dependencies need unique repository and hook IDs"): - check_config(base, proposed, False, 7, human=True) - - -def test_human_bootstrap_rejects_ambiguous_hook_identity( - configs: tuple[Path, Path], check_config: Callable[..., None] -) -> None: - base, proposed = configs - base.write_text("repos:\n - repo: local\n hooks:\n - id: a\n") - proposed.write_text( - "repos:\n - repo: local\n hooks:\n" - ' - id: a\n additional_dependencies:\n - "other==1.2.0" # release-creator managed\n' - " - id: a\n" - ) - - with pytest.raises(ValueError, match="managed hook dependencies need unique repository and hook IDs"): - check_config(base, proposed, False, 7, human=True) - - -def test_human_can_bootstrap_managed_pins_from_release_migration( - check_config: Callable[..., None], pypi_file: dict[str, object], pypi_request: MagicMock -) -> None: - def listing(request: Request, **_: object) -> BytesIO: - name, version = request.full_url.removeprefix("https://pypi.org/pypi/").removesuffix("/json").split("/") - return BytesIO(json.dumps({"info": {"name": name, "version": version}, "urls": [pypi_file]}).encode()) - - pypi_request.side_effect = listing - root = Path(__file__).resolve().parents[2] - - check_config( - Path(__file__).parent / "fixtures/pre-bootstrap.yaml", root / ".pre-commit-config.yaml", False, 7, human=True - ) - - assert pypi_request.call_count > 0 - - -def test_app_update_keeps_direct_range( - configs: tuple[Path, Path], check_config: Callable[..., None], pypi_file: dict[str, object], pypi_request: MagicMock -) -> None: - base, proposed = configs - base.write_text(base.read_text().replace("example==1.0.0", "example>=1")) - proposed.write_text( - base.read_text().replace( - " - example>=1\n", ' - example>=1\n - "other==1.2.0" # release-creator managed\n' - ) - ) - - pypi_request.return_value = _response("other", pypi_file) - check_config(base, proposed, True, 7) - assert pypi_request.call_args.args[0].full_url == "https://pypi.org/pypi/other/1.2.0/json" - - -@pytest.mark.parametrize( - "marker", - [ - pytest.param('python_version >= "3.15"', id="active"), - pytest.param('python_version < "3.15"', id="inactive"), - ], -) -def test_app_update_accepts_managed_pin_for_ranged_direct_root( - configs: tuple[Path, Path], check_config: Callable[..., None], pypi_request: MagicMock, marker: str -) -> None: - base, proposed = configs - base.write_text(base.read_text().replace("example==1.0.0", f"example>=1; {marker}")) - proposed.write_text( - base.read_text().replace( - f" - example>=1; {marker}\n", - f" - example>=1; {marker}\n - 'example==1.2.0; {marker}' # release-creator managed\n", - ) - ) - - check_config(base, proposed, True, 7) - - assert pypi_request.call_args.args[0].full_url == "https://pypi.org/pypi/example/1.2.0/json" - - -def test_app_update_accepts_managed_pin_refresh( - configs: tuple[Path, Path], check_config: Callable[..., None], pypi_file: dict[str, object], pypi_request: MagicMock -) -> None: - base, proposed = configs - base.write_text( - base.read_text().replace( - " - example==1.0.0\n", - ' - example==1.0.0\n - "other==1.0.0" # release-creator managed\n', - ) - ) - proposed.write_text(base.read_text().replace("other==1.0.0", "other==1.2.0")) - - pypi_request.return_value = _response("other", pypi_file) - check_config(base, proposed, True, 7) - assert pypi_request.call_args.args[0].full_url == "https://pypi.org/pypi/other/1.2.0/json" - - -def test_app_update_accepts_flow_mapping_managed_marker( - configs: tuple[Path, Path], check_config: Callable[..., None], pypi_file: dict[str, object], pypi_request: MagicMock -) -> None: - base, proposed = configs - proposed.write_text( - base.read_text().replace( - " - id: sample\n additional_dependencies:\n - example==1.0.0\n", - " - {id: sample, additional_dependencies: [\n" - " example==1.0.0,\n" - ' "other==1.2.0" # release-creator managed\n' - " ]}\n", - ) - ) - - pypi_request.return_value = _response("other", pypi_file) - check_config(base, proposed, True, 7) - assert pypi_request.call_args.args[0].full_url == "https://pypi.org/pypi/other/1.2.0/json" - - -def test_app_update_rejects_flow_comment_as_pin_marker( - configs: tuple[Path, Path], check_config: Callable[..., None] -) -> None: - base, proposed = configs - proposed.write_text( - base.read_text().replace( - "additional_dependencies:\n - example==1.0.0", - "additional_dependencies: [example==1.0.0, other==1.2.0] # release-creator managed", - ) - ) - - with pytest.raises(ValueError, match="changes direct dependencies"): - check_config(base, proposed, True, 7) - - -def test_app_update_rejects_duplicate_keys(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: - base, proposed = configs - proposed.write_text(base.read_text().replace(" rev: old", " rev: old\n rev: new")) - - with pytest.raises(ValueError, match="duplicate mapping keys"): - check_config(base, proposed, True, 7) - - -@pytest.mark.parametrize( - ("source", "message"), - [ - pytest.param("", "must be a mapping", id="empty-file"), - pytest.param("[]\n", "must be a mapping", id="list-root"), - pytest.param("repos: bad\n", "repositories must be lists", id="scalar-repos"), - pytest.param("repos:\n - bad\n", "repository must be a mapping", id="scalar-repository"), - pytest.param( - "repos:\n - repo: https://example.org/hook\n", "repository must contain hooks", id="missing-hooks" - ), - pytest.param( - "repos:\n - repo: https://example.org/hook\n hooks:\n - bad\n", - "hook must be a mapping", - id="scalar-hook", - ), - pytest.param( - "repos:\n - repo: https://example.org/hook\n hooks:\n" - " - id: sample\n additional_dependencies: bad\n", - "additional_dependencies must be a list", - id="scalar-dependencies", - ), - pytest.param( - "repos:\n - repo: https://example.org/hook\n hooks:\n" - " - id: sample\n additional_dependencies: [example==1.0.0, {name: bad}]\n", - "additional_dependencies must be a list", - id="mapping-dependency", - ), - ], -) -def test_app_update_rejects_malformed_hook_config( - configs: tuple[Path, Path], check_config: Callable[..., None], source: str, message: str -) -> None: - base, proposed = configs - proposed.write_text(source) - - with pytest.raises((TypeError, ValueError), match=message): - check_config(base, proposed, True, 7) - - -def test_app_update_rejects_changed_hook_count(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: - base, proposed = configs - proposed.write_text(base.read_text() + " - id: added\n") - - with pytest.raises(ValueError, match="beyond revisions and managed dependencies"): - check_config(base, proposed, True, 7) - - -def test_app_update_rejects_changed_aliased_hook(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: - base, proposed = configs - source = base.read_text().replace("- repo:", "- &repository\n repo:") - base.write_text(source.replace(" - example==1.0.0\n", " - example==1.0.0\n - *repository\n")) - proposed.write_text(base.read_text().replace("id: sample", "id: changed")) - - with pytest.raises(ValueError, match="beyond revisions and managed dependencies"): - check_config(base, proposed, True, 7) - - -def test_app_update_rejects_duplicate_dependency(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: - base, proposed = configs - proposed.write_text( - base.read_text().replace( - " - example==1.0.0\n", - ' - example==1.0.0\n - "example==1.2.0" # release-creator managed\n', - ) - ) - - with pytest.raises(ValueError, match="duplicates a dependency"): - check_config(base, proposed, True, 7) - - -@pytest.mark.parametrize( - "metadata", - [ - pytest.param({"info": {"name": "other", "version": "1.2.0"}, "urls": [{}]}, id="wrong-name"), - pytest.param({"info": {"name": "example", "version": "1.1.0"}, "urls": [{}]}, id="wrong-version"), - pytest.param({"info": {"name": "example", "version": "1.2.0"}, "urls": []}, id="no-files"), - pytest.param({"info": {"name": "example", "version": "1.2.0"}, "urls": "bad"}, id="files-type"), - ], -) -def test_app_update_rejects_invalid_pypi_listing( - configs: tuple[Path, Path], - check_config: Callable[..., None], - pypi_request: MagicMock, - metadata: dict[str, object], -) -> None: - base, proposed = configs - proposed.write_text(base.read_text().replace("example==1.0.0", "example==1.2.0")) - pypi_request.return_value = BytesIO(json.dumps(metadata).encode()) - - with pytest.raises(ValueError, match="invalid PyPI hook dependency"): - check_config(base, proposed, True, 7) - - -def test_app_cannot_change_hook_command(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: - base, proposed = configs - proposed.write_text(base.read_text().replace("id: sample", "id: other")) - - with pytest.raises(ValueError, match="beyond revisions and managed dependencies"): - check_config(base, proposed, True, 7) - - -def test_app_cannot_add_unmarked_dependency(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: - base, proposed = configs - proposed.write_text(base.read_text() + " - example==1.2.0\n") - - with pytest.raises(ValueError, match="changes direct dependencies"): - check_config(base, proposed, True, 7) - - -def test_app_cannot_shorter_cooldown(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: - base, proposed = configs - proposed.write_text(base.read_text()) - - with pytest.raises(ValueError, match="at least seven days"): - check_config(base, proposed, True, 6) - - -def test_cli_accepts_revision_only_change( - configs: tuple[Path, Path], mocker: MockerFixture, capsys: pytest.CaptureFixture[str] -) -> None: - base, proposed = configs - proposed.write_text(base.read_text().replace("rev: old", "rev: new")) - mocker.patch("sys.argv", ["check_hook_config.py", str(base), str(proposed), "--managed", "false"]) - - runpy.run_path(str(Path(__file__).resolve().parents[2] / "tools/check_hook_config.py"), run_name="__main__") - - assert capsys.readouterr() == ("", "") - - -def test_cli_reports_invalid_config( - configs: tuple[Path, Path], mocker: MockerFixture, capsys: pytest.CaptureFixture[str] -) -> None: - base, proposed = configs - proposed.write_text("repos: bad\n") - mocker.patch("sys.argv", ["check_hook_config.py", str(base), str(proposed), "--managed", "true"]) - - with pytest.raises(SystemExit, match="2"): - runpy.run_path(str(Path(__file__).resolve().parents[2] / "tools/check_hook_config.py"), run_name="__main__") - - assert "hook repositories must be lists" in capsys.readouterr().err diff --git a/tests/check_hook_update_cooldown/test_workflow.py b/tests/check_hook_update_cooldown/test_workflow.py index a3ded9f..6cdb55d 100644 --- a/tests/check_hook_update_cooldown/test_workflow.py +++ b/tests/check_hook_update_cooldown/test_workflow.py @@ -1,60 +1,13 @@ from __future__ import annotations -import json import os import re import subprocess from pathlib import Path -import pytest import yaml -@pytest.mark.parametrize( - ("event_name", "cooldown_result", "accepted"), - [ - pytest.param("pull_request", "success", True, id="cooled-pr"), - pytest.param("pull_request", "skipped", False, id="skipped-pr"), - pytest.param("pull_request", "failure", False, id="young-bot-pr"), - pytest.param("push", "skipped", True, id="push"), - ], -) -def test_required_gate_checks_cooldown_result(event_name: str, cooldown_result: str, accepted: bool) -> None: - workflow = yaml.load( - (Path(__file__).resolve().parents[2] / ".github/workflows/check.yaml").read_text(), - Loader=yaml.BaseLoader, - ) - script = workflow["jobs"]["gate"]["steps"][0]["run"] - needs = { - name: { - "result": cooldown_result - if name == "hook-cooldown" - else "skipped" - if event_name == "push" and name == "hook-cooldown" - else "success" - } - for name in workflow["jobs"]["gate"]["needs"] - } - result = subprocess.run( - ["bash", "-c", script], - env=os.environ | {"NEEDS_JSON": json.dumps(needs), "EVENT_NAME": event_name}, - text=True, - capture_output=True, - check=False, - ) - assert (result.returncode == 0) is accepted, result.stderr - - -def test_required_gate_does_not_repeat_changelog_lock_check() -> None: - root = Path(__file__).resolve().parents[2] - check = yaml.load((root / ".github/workflows/check.yaml").read_text(), Loader=yaml.BaseLoader) - news = yaml.load((root / ".github/workflows/news.yaml").read_text(), Loader=yaml.BaseLoader) - - assert "release-tools-preview" not in check["jobs"] - assert check["jobs"]["gate"]["needs"] == ["check", "shell", "test", "type", "hook-cooldown"] - assert news["jobs"]["changelog"]["steps"][-1]["uses"] == "$/validate-news" - - def test_tagged_source_checks_frozen_hooks_before_prek() -> None: root = Path(__file__).resolve().parents[2] prepare = yaml.load((root / ".github/workflows/prepare-towncrier-release.yaml").read_text(), Loader=yaml.BaseLoader) diff --git a/tests/check_workflow/test_gate.py b/tests/check_workflow/test_gate.py new file mode 100644 index 0000000..762defd --- /dev/null +++ b/tests/check_workflow/test_gate.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest +import yaml + + +@pytest.mark.parametrize( + ("failed_result", "accepted"), + [ + pytest.param(None, True, id="all-succeeded"), + pytest.param("skipped", False, id="skipped-job"), + pytest.param("failure", False, id="failed-job"), + ], +) +def test_required_gate_requires_every_job_to_succeed(failed_result: str | None, accepted: bool) -> None: + workflow = yaml.load( + (Path(__file__).resolve().parents[2] / ".github/workflows/check.yaml").read_text(), + Loader=yaml.BaseLoader, + ) + names = workflow["jobs"]["gate"]["needs"] + needs = {name: {"result": "success"} for name in names} + if failed_result is not None: + needs[names[-1]] = {"result": failed_result} + result = subprocess.run( + ["bash", "-c", workflow["jobs"]["gate"]["steps"][0]["run"]], + env=os.environ | {"NEEDS_JSON": json.dumps(needs)}, + text=True, + capture_output=True, + check=False, + ) + assert (result.returncode == 0) is accepted, result.stderr + + +def test_required_gate_does_not_repeat_changelog_lock_check() -> None: + root = Path(__file__).resolve().parents[2] + check = yaml.load((root / ".github/workflows/check.yaml").read_text(), Loader=yaml.BaseLoader) + news = yaml.load((root / ".github/workflows/news.yaml").read_text(), Loader=yaml.BaseLoader) + + assert "release-tools-preview" not in check["jobs"] + assert check["jobs"]["gate"]["needs"] == ["check", "shell", "test", "type"] + assert news["jobs"]["changelog"]["steps"][-1]["uses"] == "$/validate-news" diff --git a/tests/conftest.py b/tests/conftest.py index a9c82c5..99cf9df 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -71,8 +71,6 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: "reconcile-pypi-artifacts", "publish-jetbrains-marketplace", "sign-jetbrains-plugin", - "check-hook-update-cooldown", - "recheck-hook-update-cooldown", "publish-post-release-pr", "verify-frozen-hooks", ): @@ -372,8 +370,6 @@ def _run_action( if (condition := step.get("if")) is not None and not _condition(condition, environment, inputs, step_outputs): continue if (used := step.get("uses")) is not None: - if used.startswith("astral-sh/setup-uv@"): - continue if used.startswith("actions/upload-artifact@"): continue nested_name = used.removeprefix("$/") diff --git a/tests/recheck_hook_update_cooldown/test_recheck.py b/tests/recheck_hook_update_cooldown/test_recheck.py deleted file mode 100644 index 043604f..0000000 --- a/tests/recheck_hook_update_cooldown/test_recheck.py +++ /dev/null @@ -1,359 +0,0 @@ -from __future__ import annotations - -from datetime import UTC, datetime, timedelta -from pathlib import Path - -import pytest -import yaml -from conftest import action -from pytest_httpserver import HTTPServer - - -def test_recheck_schedule_has_only_required_permissions() -> None: - workflow = yaml.load( - (Path(__file__).resolve().parents[2] / ".github/workflows/recheck-hook-cooldown.yaml").read_text(), - Loader=yaml.BaseLoader, - ) - assert workflow["jobs"]["recheck"] == { - "runs-on": "ubuntu-26.04", - "permissions": {"actions": "write", "contents": "read", "pull-requests": "read"}, - "steps": [{"uses": "$/recheck-hook-update-cooldown"}], - } - - -@pytest.mark.parametrize( - ("observed_age", "job_age", "job_conclusion", "job_name", "configured_job", "workflow", "reruns"), - [ - pytest.param(8, 8, "failure", "hook-cooldown", "hook-cooldown", "check.yaml", 1, id="matured-failure"), - pytest.param(8, 8, "failure", "Hook update cooldown", "hook-cooldown", "check.yaml", 0, id="other-job"), - pytest.param( - 8, - 8, - "failure", - "Hook update cooldown", - "Hook update cooldown", - "pr-gate.yaml", - 1, - id="configured-job-and-workflow", - ), - pytest.param(6, 6, "failure", "hook-cooldown", "hook-cooldown", "check.yaml", 0, id="recent-pull"), - pytest.param(8, 0, "failure", "hook-cooldown", "hook-cooldown", "check.yaml", 1, id="failed-rerun"), - pytest.param(31, 31, "failure", "hook-cooldown", "hook-cooldown", "check.yaml", 0, id="expired-run"), - pytest.param(8, 8, "success", "hook-cooldown", "hook-cooldown", "check.yaml", 0, id="successful-job"), - ], -) -@pytest.mark.parametrize( - ("login", "author_type", "head_repository"), - [ - pytest.param("pre-commit-ci[bot]", "Bot", "tox-dev/fixture", id="pre-commit-ci"), - pytest.param("release-app[bot]", "Bot", "tox-dev/fixture", id="app"), - pytest.param("gaborbernat", "User", "tox-dev/fixture", id="human"), - pytest.param("gaborbernat", "User", "gaborbernat/fixture", id="human-fork"), - ], -) -def test_recheck_reruns_failed_job( - github_api: tuple[HTTPServer, dict[str, str]], - action_scripts: Path, - tmp_path: Path, - observed_age: int, - job_age: int, - job_conclusion: str, - job_name: str, - configured_job: str, - workflow: str, - reruns: int, - login: str, - author_type: str, - head_repository: str, -) -> None: - server, environment = github_api - observed_at = (datetime.now(UTC) - timedelta(days=observed_age)).strftime("%Y-%m-%dT%H:%M:%SZ") - server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json( - [ - { - "number": 7, - "user": {"login": login, "type": author_type}, - "head": { - "sha": "a" * 40, - "ref": "pre-commit-ci-update-config", - "repo": {"full_name": head_repository}, - }, - "base": {"repo": {"full_name": "tox-dev/fixture"}}, - "created_at": (datetime.now(UTC) - timedelta(days=20)).strftime("%Y-%m-%dT%H:%M:%SZ"), - "updated_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), - } - ] - ) - server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7/files", method="GET").respond_with_json( - [{"filename": ".pre-commit-config.yaml"}] - ) - server.expect_request( - f"/api/v3/repos/tox-dev/fixture/actions/workflows/{workflow}/runs", method="GET" - ).respond_with_json( - { - "workflow_runs": [ - { - "id": 42, - "event": "pull_request", - "head_repository": {"full_name": head_repository}, - "head_sha": "a" * 40, - "head_branch": "pre-commit-ci-update-config", - "pull_requests": [{"number": 7}], - "status": "completed", - "created_at": observed_at, - } - ] - } - ) - if observed_age >= 7 and observed_age < 30: - server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42/jobs", method="GET").respond_with_json( - { - "jobs": [ - {"id": 98, "name": "Other failed job", "conclusion": "failure", "completed_at": observed_at}, - { - "id": 99, - "name": job_name, - "conclusion": job_conclusion, - "completed_at": (datetime.now(UTC) - timedelta(days=job_age)).strftime("%Y-%m-%dT%H:%M:%SZ"), - }, - ] - } - ) - if reruns: - server.expect_request("/api/v3/repos/tox-dev/fixture/actions/jobs/99", method="GET").respond_with_json( - {"id": 99, "run_id": 42, "name": job_name, "status": "completed", "conclusion": "failure"} - ) - server.expect_request("/api/v3/repos/tox-dev/fixture/actions/jobs/99/rerun", method="POST").respond_with_data( - status=201 - ) - result = action( - tmp_path, - tmp_path, - action_scripts, - "recheck-hook-update-cooldown", - version="", - GH_TOKEN=environment["GH_ENTERPRISE_TOKEN"], - JOB_NAME=configured_job, - WORKFLOW=workflow, - **environment, - ) - assert result.returncode == 0, result.stderr - assert [request.path for request, _ in server.log].count( - "/api/v3/repos/tox-dev/fixture/actions/jobs/99/rerun" - ) == reruns - - -@pytest.mark.parametrize( - ("head_repository", "opened_days", "reruns"), - [ - pytest.param("tox-dev/fixture", 20, 1, id="same-repository-unlinked"), - pytest.param("gaborbernat/fixture", 20, 1, id="fork-unlinked"), - pytest.param("gaborbernat/fixture", 5, 0, id="observation-predates-pr"), - ], -) -def test_recheck_observes_unlinked_exact_head( - github_api: tuple[HTTPServer, dict[str, str]], - action_scripts: Path, - tmp_path: Path, - head_repository: str, - opened_days: int, - reruns: int, -) -> None: - server, environment = github_api - observed_at = (datetime.now(UTC) - timedelta(days=8)).strftime("%Y-%m-%dT%H:%M:%SZ") - server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json( - [ - { - "number": 7, - "head": {"sha": "a" * 40, "ref": "update", "repo": {"full_name": head_repository}}, - "base": {"repo": {"full_name": "tox-dev/fixture"}}, - "created_at": (datetime.now(UTC) - timedelta(days=opened_days)).strftime("%Y-%m-%dT%H:%M:%SZ"), - } - ] - ) - server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7/files", method="GET").respond_with_json( - [{"filename": ".pre-commit-config.yaml"}] - ) - server.expect_request( - "/api/v3/repos/tox-dev/fixture/actions/workflows/check.yaml/runs", method="GET" - ).respond_with_json( - { - "workflow_runs": [ - { - "id": 42, - "event": "pull_request", - "head_repository": {"full_name": head_repository}, - "head_sha": "a" * 40, - "head_branch": "update", - "pull_requests": [], - "status": "completed", - "created_at": observed_at, - } - ] - } - ) - if reruns: - server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42/jobs", method="GET").respond_with_json( - {"jobs": [{"id": 99, "name": "hook-cooldown", "conclusion": "failure"}]} - ) - server.expect_request("/api/v3/repos/tox-dev/fixture/actions/jobs/99", method="GET").respond_with_json( - {"id": 99, "run_id": 42, "name": "hook-cooldown", "status": "completed", "conclusion": "failure"} - ) - server.expect_request("/api/v3/repos/tox-dev/fixture/actions/jobs/99/rerun", method="POST").respond_with_data( - status=201 - ) - - result = action( - tmp_path, - tmp_path, - action_scripts, - "recheck-hook-update-cooldown", - version="", - GH_TOKEN=environment["GH_ENTERPRISE_TOKEN"], - **environment, - ) - - assert result.returncode == 0, result.stderr - assert [request.path for request, _ in server.log].count( - "/api/v3/repos/tox-dev/fixture/actions/jobs/99/rerun" - ) == reruns - - -def test_recheck_continues_after_failed_rerun( - github_api: tuple[HTTPServer, dict[str, str]], action_scripts: Path, tmp_path: Path -) -> None: - server, environment = github_api - observed_at = (datetime.now(UTC) - timedelta(days=8)).strftime("%Y-%m-%dT%H:%M:%SZ") - server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json( - [ - { - "number": number, - "user": {"login": "release-app[bot]", "type": "Bot"}, - "head": {"sha": str(number) * 40, "ref": f"update-{number}", "repo": {"full_name": "tox-dev/fixture"}}, - "base": {"repo": {"full_name": "tox-dev/fixture"}}, - "created_at": (datetime.now(UTC) - timedelta(days=20)).strftime("%Y-%m-%dT%H:%M:%SZ"), - } - for number in (7, 8) - ] - ) - for number in (7, 8): - server.expect_request(f"/api/v3/repos/tox-dev/fixture/pulls/{number}/files", method="GET").respond_with_json( - [{"filename": ".pre-commit-config.yaml"}] - ) - server.expect_request( - "/api/v3/repos/tox-dev/fixture/actions/workflows/check.yaml/runs", - method="GET", - query_string=f"branch=update-{number}&event=pull_request&per_page=100", - ).respond_with_json( - { - "workflow_runs": [ - { - "id": number + 40, - "event": "pull_request", - "head_repository": {"full_name": "tox-dev/fixture"}, - "head_sha": str(number) * 40, - "head_branch": f"update-{number}", - "pull_requests": [{"number": number}], - "status": "completed", - "created_at": observed_at, - } - ] - } - ) - job = number + 90 - server.expect_request( - f"/api/v3/repos/tox-dev/fixture/actions/runs/{number + 40}/jobs", method="GET" - ).respond_with_json({"jobs": [{"id": job, "name": "hook-cooldown", "conclusion": "failure"}]}) - server.expect_request(f"/api/v3/repos/tox-dev/fixture/actions/jobs/{job}", method="GET").respond_with_json( - {"id": job, "run_id": number + 40, "name": "hook-cooldown", "status": "completed"} - ) - server.expect_request( - f"/api/v3/repos/tox-dev/fixture/actions/jobs/{job}/rerun", method="POST" - ).respond_with_data(status=500 if number == 7 else 201) - result = action( - tmp_path, - tmp_path, - action_scripts, - "recheck-hook-update-cooldown", - version="", - GH_TOKEN=environment["GH_ENTERPRISE_TOKEN"], - **environment, - ) - assert result.returncode == 0, result.stderr - assert "could not rerun hook cooldown for pull request #7" in result.stderr - assert [request.path for request, _ in server.log if request.method == "POST"] == [ - "/api/v3/repos/tox-dev/fixture/actions/jobs/97/rerun", - "/api/v3/repos/tox-dev/fixture/actions/jobs/98/rerun", - ] - - -@pytest.mark.parametrize( - ("older_head_age", "reruns"), - [pytest.param(8, 1, id="older-head-matured"), pytest.param(3, 0, id="all-heads-recent")], -) -def test_recheck_counts_observations_on_earlier_heads( - github_api: tuple[HTTPServer, dict[str, str]], - action_scripts: Path, - tmp_path: Path, - older_head_age: int, - reruns: int, -) -> None: - server, environment = github_api - server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json( - [ - { - "number": 7, - "head": {"sha": "a" * 40, "ref": "update", "repo": {"full_name": "tox-dev/fixture"}}, - "base": {"repo": {"full_name": "tox-dev/fixture"}}, - "created_at": (datetime.now(UTC) - timedelta(days=20)).strftime("%Y-%m-%dT%H:%M:%SZ"), - } - ] - ) - server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7/files", method="GET").respond_with_json( - [{"filename": ".pre-commit-config.yaml"}] - ) - server.expect_request( - "/api/v3/repos/tox-dev/fixture/actions/workflows/check.yaml/runs", - method="GET", - query_string="branch=update&event=pull_request&per_page=100", - ).respond_with_json( - { - "workflow_runs": [ - { - "id": run_id, - "event": "pull_request", - "head_repository": {"full_name": "tox-dev/fixture"}, - "head_sha": head, - "head_branch": "update", - "pull_requests": [{"number": 7}], - "status": "completed", - "created_at": (datetime.now(UTC) - timedelta(days=age)).strftime("%Y-%m-%dT%H:%M:%SZ"), - } - for run_id, head, age in ((41, "b" * 40, older_head_age), (42, "a" * 40, 1)) - ] - } - ) - server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42/jobs", method="GET").respond_with_json( - {"jobs": [{"id": 99, "name": "hook-cooldown", "conclusion": "failure"}]} - ) - server.expect_request("/api/v3/repos/tox-dev/fixture/actions/jobs/99", method="GET").respond_with_json( - {"id": 99, "run_id": 42, "name": "hook-cooldown", "status": "completed", "conclusion": "failure"} - ) - server.expect_request("/api/v3/repos/tox-dev/fixture/actions/jobs/99/rerun", method="POST").respond_with_data( - status=201 - ) - - result = action( - tmp_path, - tmp_path, - action_scripts, - "recheck-hook-update-cooldown", - version="", - GH_TOKEN=environment["GH_ENTERPRISE_TOKEN"], - **environment, - ) - - assert result.returncode == 0, result.stderr - assert [request.path for request, _ in server.log].count( - "/api/v3/repos/tox-dev/fixture/actions/jobs/99/rerun" - ) == reruns diff --git a/tools/check_hook_config.py b/tools/check_hook_config.py deleted file mode 100644 index 65d2cc6..0000000 --- a/tools/check_hook_config.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Check the hook changes allowed in a pull request.""" - -from __future__ import annotations - -import argparse -import json -import re -import ssl -from collections import Counter -from collections.abc import Iterator -from copy import deepcopy -from datetime import UTC, datetime, timedelta -from pathlib import Path -from typing import cast -from urllib.parse import quote, urlsplit -from urllib.request import Request, urlopen - -import certifi -from packaging.requirements import InvalidRequirement, Requirement -from packaging.utils import canonicalize_name -from packaging.version import Version -from yaml import MappingNode, ScalarNode, SequenceNode, YAMLError, compose, safe_load -from yaml.error import Mark - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("base", type=Path) - parser.add_argument("proposed", type=Path) - parser.add_argument("--managed", choices=("true", "false", ""), default="") - parser.add_argument("--human", choices=("true", "false", ""), default="") - parser.add_argument("--minimum-days", type=int, default=7) - args = parser.parse_args() - try: - check_config(args.base, args.proposed, args.managed == "true", args.minimum_days, human=args.human == "true") - except (InvalidRequirement, KeyError, OSError, TypeError, ValueError, YAMLError) as error: - parser.error(str(error)) - - -def check_config(base: Path, proposed: Path, managed: bool, minimum_days: int, *, human: bool = False) -> None: - if minimum_days < 7: - raise ValueError("hook cooldown must be at least seven days") - base_source = base.read_text(encoding="utf-8") - proposed_source = proposed.read_text(encoding="utf-8") - _reject_duplicate_keys(base_source) - _reject_duplicate_keys(proposed_source) - old = safe_load(base_source) - new = safe_load(proposed_source) - if not isinstance(old, dict) or not isinstance(new, dict): - raise TypeError("hook configuration must be a mapping") - if human: - if not isinstance(old.get("repos"), list) or not isinstance(new.get("repos"), list): - raise TypeError("hook repositories must be lists") - added = _managed_pin_locations(proposed_source, new) - _managed_pin_locations(base_source, old) - for _, _, item in added: - _check_pypi(Requirement(item), minimum_days) - return - old_content = deepcopy(old) - new_content = deepcopy(new) - old_repos = old_content.get("repos") - new_repos = new_content.get("repos") - if not isinstance(old_repos, list) or not isinstance(new_repos, list): - raise TypeError("hook repositories must be lists") - for repository in (*old_repos, *new_repos): - if not isinstance(repository, dict): - raise TypeError("hook repository must be a mapping") - repository.pop("rev", None) - if managed: - old_pins = list(_dependency_groups(base_source)) - new_pins = list(_dependency_groups(proposed_source)) - if len(old_pins) != len(new_pins): - raise ValueError("hook update changes fields beyond revisions and managed dependencies") - for old_group, new_group in zip(old_pins, new_pins, strict=True): - _check_dependency_group(old_group, new_group, minimum_days) - for repository in (*old_repos, *new_repos): - for hook in repository.get("hooks", []): - hook.pop("additional_dependencies", None) - if old_content != new_content: - raise ValueError( - "hook update changes fields beyond revisions and managed dependencies" - if managed - else "hook update changes fields beyond revisions" - ) - - -def _managed_pin_locations(source: str, config: dict[str, object]) -> Counter[tuple[str, str, str]]: - groups = iter(_dependency_groups(source)) - locations: Counter[tuple[str, str, str]] = Counter() - identities: set[tuple[str, str]] = set() - duplicate = False - for repository in cast("list[dict[str, object]]", config["repos"]): - url = cast("str", repository["repo"]) - for hook in cast("list[dict[str, object]]", repository["hooks"]): - hook_id = cast("str", hook["id"]) - if (url, hook_id) in identities: - duplicate = True - identities.add((url, hook_id)) - locations.update((url, hook_id, item) for item, managed in next(groups) if managed) - if locations and duplicate: - raise ValueError("managed hook dependencies need unique repository and hook IDs") - return locations - - -def _dependency_groups(source: str) -> Iterator[list[tuple[str, bool]]]: - root = cast("MappingNode", compose(source)) - repositories = cast("SequenceNode", _value(root, "repos")) - for repository in repositories.value: - hooks = _value(cast("MappingNode", repository), "hooks") - if not isinstance(hooks, SequenceNode): - raise TypeError("hook repository must contain hooks") - for hook in hooks.value: - if not isinstance(hook, MappingNode): - raise TypeError("hook must be a mapping") - dependencies = _value(hook, "additional_dependencies") - if dependencies is None: - yield [] - continue - if not isinstance(dependencies, SequenceNode) or not all( - isinstance(item, ScalarNode) for item in dependencies.value - ): - raise TypeError("additional_dependencies must be a list of package strings") - yield [(item.value, _managed_marker(source, item)) for item in dependencies.value] - - -def _managed_marker(source: str, item: ScalarNode) -> bool: - suffix = source[cast("Mark", item.end_mark).index :].split("\n", 1)[0] - return ( - re.match( - r""" - ^ \s* ,? \s* - [#] \s+ release-creator \s+ managed - (?: \s | $ ) - """, - suffix, - re.VERBOSE, - ) - is not None - ) - - -def _reject_duplicate_keys(source: str) -> None: - root = compose(source) - pending = [root] - visited: set[int] = set() - while pending: - node = pending.pop() - if node is None or id(node) in visited: - continue - visited.add(id(node)) - if isinstance(node, MappingNode): - names = [key.value for key, _ in node.value if isinstance(key, ScalarNode)] - if len(names) != len(set(names)): - raise ValueError("hook configuration contains duplicate mapping keys") - pending.extend(child for pair in node.value for child in pair) - elif isinstance(node, SequenceNode): - pending.extend(node.value) - - -def _value(node: MappingNode, name: str) -> object: - return next((value for key, value in node.value if isinstance(key, ScalarNode) and key.value == name), None) - - -def _check_dependency_group(old: list[tuple[str, bool]], new: list[tuple[str, bool]], minimum_days: int) -> None: - old_direct = [item for item, managed in old if not managed] - new_direct = [item for item, managed in new if not managed] - if len(old_direct) != len(new_direct): - raise ValueError("hook update changes direct dependencies") - for previous, proposed in zip(old_direct, new_direct, strict=True): - if previous == proposed: - continue - old_requirement = Requirement(previous) - new_requirement = Requirement(proposed) - if ( - canonicalize_name(old_requirement.name) != canonicalize_name(new_requirement.name) - or old_requirement.extras != new_requirement.extras - or old_requirement.marker != new_requirement.marker - or old_requirement.url is not None - or new_requirement.url is not None - or not _exact(old_requirement) - ): - raise ValueError("hook update changes direct dependencies beyond an exact pin") - _check_pypi(new_requirement, minimum_days) - direct_requirements = [Requirement(item) for item in new_direct] - direct_names = [canonicalize_name(requirement.name) for requirement in direct_requirements] - managed_names = [canonicalize_name(Requirement(item).name) for item, marked in new if marked] - managed_set = set(managed_names) - if ( - len(direct_names) != len(set(direct_names)) - or len(managed_names) != len(managed_set) - or any( - canonicalize_name(requirement.name) in managed_set and _exact(requirement) - for requirement in direct_requirements - ) - ): - raise ValueError("hook update duplicates a dependency") - for item, marked in new: - if marked and item not in {previous for previous, old_marked in old if old_marked}: - _check_pypi(Requirement(item), minimum_days) - - -def _exact(requirement: Requirement) -> bool: - return ( - requirement.url is None - and len(requirement.specifier) == 1 - and (specifier := next(iter(requirement.specifier))).operator == "==" - and "*" not in specifier.version - ) - - -def _check_pypi(requirement: Requirement, minimum_days: int) -> None: - if not _exact(requirement): - raise ValueError(f"hook dependency is not an exact PyPI pin: {requirement}") - version = Version(next(iter(requirement.specifier)).version) - name = canonicalize_name(requirement.name) - request = Request( - f"https://pypi.org/pypi/{quote(name)}/{quote(str(version))}/json", - headers={"Cache-Control": "no-cache"}, - ) - with urlopen(request, timeout=10, context=ssl.create_default_context(cafile=certifi.where())) as response: - listing = json.load(response) - files = listing.get("urls") - if ( - canonicalize_name(listing.get("info", {}).get("name", "")) != name - or Version(listing.get("info", {}).get("version", "0")) != version - or not isinstance(files, list) - or not files - ): - raise ValueError(f"invalid PyPI hook dependency: {requirement}") - cutoff = datetime.now(UTC) - timedelta(days=minimum_days) - for file in files: - url = urlsplit(file.get("url", "")) - uploaded = datetime.fromisoformat(file.get("upload_time_iso_8601", "")) - if ( - file.get("yanked") - or url.scheme != "https" - or url.netloc != "files.pythonhosted.org" - or not file.get("digests", {}).get("sha256") - or uploaded.tzinfo is None - or uploaded > cutoff - ): - raise ValueError(f"hook dependency has an uncooled or untrusted PyPI artifact: {requirement}") - - -if __name__ == "__main__": - main() - - -__all__ = ["check_config", "main"] From 7192946b9036a31a1e6d265ab1de7cc7859f318a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 12:39:47 -0700 Subject: [PATCH 02/35] =?UTF-8?q?=F0=9F=94=A5=20feat(hooks)!:=20drop=20the?= =?UTF-8?q?=20frozen=20hook=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove verify-frozen-hooks, check_frozen_hooks.py and check_tagged_hook_age.py, and their calls from the prepare workflow and check-tagged-source. The prek.toml and nested configuration rejection moves next to the prek runs in prepare-towncrier and validate-release-patch. --- .../workflows/prepare-towncrier-release.yaml | 1 - README.md | 32 +- changelog.d/+frozen-hooks.breaking.md | 4 + check-tagged-source/action.yml | 3 +- prepare-towncrier/action.yml | 11 + .../test_frozen_hooks.py | 89 --- .../test_tagged_age.py | 529 ------------------ tests/conftest.py | 19 - .../test_cooled_prek.py} | 10 - tests/prepare_towncrier/test_prepare.py | 23 + tests/validate_release_patch/test_validate.py | 24 + .../test_frozen_hook_action.py | 98 ---- tools/check_frozen_hooks.py | 50 -- tools/check_tagged_hook_age.py | 206 ------- validate-release-patch/action.yml | 11 + verify-frozen-hooks/action.yml | 44 -- 16 files changed, 82 insertions(+), 1072 deletions(-) create mode 100644 changelog.d/+frozen-hooks.breaking.md delete mode 100644 tests/check_hook_update_cooldown/test_frozen_hooks.py delete mode 100644 tests/check_hook_update_cooldown/test_tagged_age.py rename tests/{check_hook_update_cooldown/test_workflow.py => cooled_prek/test_cooled_prek.py} (81%) delete mode 100644 tests/verify_frozen_hooks/test_frozen_hook_action.py delete mode 100644 tools/check_frozen_hooks.py delete mode 100644 tools/check_tagged_hook_age.py delete mode 100644 verify-frozen-hooks/action.yml diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 3cd3030..bc92d93 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -99,7 +99,6 @@ jobs: groups: release-prepare${{ inputs.local-tool-source && ' project-runtime' || '' }} venv: .venv local-tool-source: ${{ inputs.local-tool-source }} - - uses: $/verify-frozen-hooks - name: Install prek hooks id: hooks env: diff --git a/README.md b/README.md index 530673d..c39390d 100644 --- a/README.md +++ b/README.md @@ -191,33 +191,17 @@ The weekly updater freezes eligible hook revisions with `prek update --freeze -- checks the patch paths and file modes before obtaining an App token. Every shared `prek` command passes `--config .pre-commit-config.yaml`. Without it, prek prefers a root `prek.toml` and -also runs nested configurations, which no guard checks. `verify-frozen-hooks` therefore rejects a `prek.toml` anywhere, -a `.pre-commit-config.yml`, and a `.pre-commit-config.yaml` below the root. - -On a tag, `verify-frozen-hooks` also checks when each new external `(repository, commit)` pair was first present in -GitHub. It compares the tag with the previous published release in the same tag stream and on the first-parent release -line. A new pair must come from a merged PR, and either its merge or a PR run containing the exact pair must be at least -seven days old. The first release has no baseline: every external pair needs that proof. Directly pushed hook changes -cannot acquire it. The proof runs before `release-creator hooks --check` builds any hook repository. This read-only -Action has no outputs; it fails before `prek` runs. The tagged source job needs `actions: read`, `contents: read`, -`pull-requests: read`, and a checkout with `fetch-depth: 0`. - -For a release dispatched from `main` with a tag input, pass that tag to the Action. It checks that the checkout is the -tag's commit; a dispatch on `main` with the wrong checkout fails. - -```yaml - - uses: tox-dev/github-workflows/verify-frozen-hooks@SHA - with: - tag: ${{ inputs.tag }} -``` +also runs nested configurations, which no guard checks. `prepare-towncrier` and `validate-release-patch` therefore +reject a `prek.toml` anywhere, a `.pre-commit-config.yml`, and a `.pre-commit-config.yaml` below the root before they +run `prek`. The shared prepare and tagged validator run `refresh-release-lock` in `check` mode before release checks or publishing. `check-tagged-source` bundles the tagged checks for a custom tag publisher: it requires `GITHUB_REF` to be the tag of -the checkout on `main`, checks the locks, installs `release-check`, runs `verify-frozen-hooks`, and then runs -`prek run --all-files` with the seven-day cutoff. It takes `keep-uv-lock` and the secondary lock inputs below, and needs -the same permissions and full-history checkout as `verify-frozen-hooks`. Pass `keep-uv-lock: true` when the repository -commits a native `uv.lock`. For a secondary Nab lock, pass its path, selected groups, selection type, and matrix inputs -to both workflows. A stale primary, native, or secondary lock stops the release before artifacts are built. +the checkout on `main`, checks the locks, installs `release-check`, and then runs `prek run --all-files` with the +seven-day cutoff. It takes `keep-uv-lock` and the secondary lock inputs below, and needs a full-history checkout. Pass +`keep-uv-lock: true` when the repository commits a native `uv.lock`. For a secondary Nab lock, pass its path, selected +groups, selection type, and matrix inputs to both workflows. A stale primary, native, or secondary lock stops the +release before artifacts are built. For example, a tagged source job that also publishes from a PGO lock checks both files before installing build tools: diff --git a/changelog.d/+frozen-hooks.breaking.md b/changelog.d/+frozen-hooks.breaking.md new file mode 100644 index 0000000..8cbbdfb --- /dev/null +++ b/changelog.d/+frozen-hooks.breaking.md @@ -0,0 +1,4 @@ +Remove the `verify-frozen-hooks` Action with its frozen revision, tagged hook provenance, and hook dependency closure +checks. The shared prepare workflow and `check-tagged-source` no longer run it; drop it from custom tag jobs. +`prepare-towncrier` and `validate-release-patch` now reject a `prek.toml` or a nested hook configuration before they +run `prek`. diff --git a/check-tagged-source/action.yml b/check-tagged-source/action.yml index 60a40a5..14160f2 100644 --- a/check-tagged-source/action.yml +++ b/check-tagged-source/action.yml @@ -1,5 +1,5 @@ name: Check tagged source -description: Check the tag, the project lock, and the frozen hooks of a tagged checkout, then run its cooled hooks +description: Check the tag and the project lock of a tagged checkout, then run its cooled hooks inputs: keep-uv-lock: description: Check a committed native uv.lock alongside pylock.toml @@ -49,7 +49,6 @@ runs: with: groups: release-check venv: ${{ runner.temp }}/source-check-tools - - uses: $/verify-frozen-hooks - name: Run tagged source hooks shell: bash run: '"$GITHUB_ACTION_PATH/../tools/cooled_prek.sh" run --all-files' diff --git a/prepare-towncrier/action.yml b/prepare-towncrier/action.yml index 82edc19..e203db5 100644 --- a/prepare-towncrier/action.yml +++ b/prepare-towncrier/action.yml @@ -93,6 +93,17 @@ runs: ;; esac towncrier build --yes --version "$CHANGELOG_PREFIX${VERSION#v}" + - name: Reject other hook configurations + if: inputs.phase != 'build' + shell: bash + run: | + set -euo pipefail + others=$(git ls-files -- ':(glob)**/prek.toml' ':(glob)**/.pre-commit-config.yml' \ + ':(glob)*/**/.pre-commit-config.yaml') + if [[ -n "$others" ]]; then + echo "::error::hooks must be configured only in the root .pre-commit-config.yaml: ${others//$'\n'/, }" >&2 + exit 1 + fi - name: Format changelog if: inputs.phase != 'build' shell: bash diff --git a/tests/check_hook_update_cooldown/test_frozen_hooks.py b/tests/check_hook_update_cooldown/test_frozen_hooks.py deleted file mode 100644 index 25cfaa6..0000000 --- a/tests/check_hook_update_cooldown/test_frozen_hooks.py +++ /dev/null @@ -1,89 +0,0 @@ -from __future__ import annotations - -import runpy -import subprocess -import sys -from pathlib import Path - -import pytest - - -@pytest.mark.parametrize( - ("revision", "accepted"), - [ - pytest.param("a" * 40, True, id="frozen"), - pytest.param("v1.0.0", False, id="tag"), - pytest.param("a" * 12, False, id="short-sha"), - pytest.param("A" * 40, False, id="uppercase-sha"), - ], -) -def test_frozen_hook_revision(tmp_path: Path, revision: str, accepted: bool) -> None: - config = tmp_path / ".pre-commit-config.yaml" - config.write_text(f"repos:\n - repo: https://example.org/hooks\n rev: {revision}\n hooks: []\n") - result = subprocess.run( - [sys.executable, str(Path(__file__).resolve().parents[2] / "tools/check_frozen_hooks.py"), str(config)], - text=True, - capture_output=True, - check=False, - ) - - assert (result.returncode == 0) is accepted - if not accepted: - assert "full lowercase commit SHA" in result.stderr - - -@pytest.mark.parametrize( - "hooks", - [ - pytest.param("[]", id="no-hooks"), - pytest.param( - "[{id: lint, language: python, additional_dependencies: [ruff==0.1.0]}]", id="python-dependencies" - ), - pytest.param("[{id: lint, language: node}]", id="node-without-dependencies"), - ], -) -def test_frozen_hook_revision_allows_local_hook(tmp_path: Path, hooks: str) -> None: - config = tmp_path / ".pre-commit-config.yaml" - config.write_text(f"repos:\n - repo: local\n hooks: {hooks}\n") - result = subprocess.run( - [sys.executable, str(Path(__file__).resolve().parents[2] / "tools/check_frozen_hooks.py"), str(config)], - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode == 0, result.stderr - - -@pytest.mark.parametrize( - ("config_text", "message"), - [ - pytest.param("repos: null\n", "repository list", id="missing-list"), - pytest.param("repos:\n - local\n", "mapping with a URL", id="invalid-repository"), - pytest.param("repos:\n - repo: local\n hooks: [x]\n", "list of mappings", id="invalid-hooks"), - pytest.param( - "repos:\n - repo: local\n hooks:\n - id: lint\n language: node\n" - " additional_dependencies: [prettier@latest]\n", - "only Python hooks may declare additional_dependencies: lint", - id="node-dependencies", - ), - ], -) -def test_frozen_hook_revision_rejects_malformed_config(tmp_path: Path, config_text: str, message: str) -> None: - config = tmp_path / ".pre-commit-config.yaml" - config.write_text(config_text) - result = subprocess.run( - [sys.executable, str(Path(__file__).resolve().parents[2] / "tools/check_frozen_hooks.py"), str(config)], - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode != 0 - assert message in result.stderr - - -def test_frozen_hook_import_has_no_cli_side_effect(capsys: pytest.CaptureFixture[str]) -> None: - runpy.run_path(str(Path(__file__).resolve().parents[2] / "tools/check_frozen_hooks.py"), run_name="frozen_hooks") - - assert capsys.readouterr() == ("", "") diff --git a/tests/check_hook_update_cooldown/test_tagged_age.py b/tests/check_hook_update_cooldown/test_tagged_age.py deleted file mode 100644 index 7279550..0000000 --- a/tests/check_hook_update_cooldown/test_tagged_age.py +++ /dev/null @@ -1,529 +0,0 @@ -from __future__ import annotations - -import base64 -import os -import subprocess -from datetime import UTC, datetime, timedelta -from pathlib import Path - -import coverage -import pytest -import yaml -from pytest_httpserver import HTTPServer - -from tools.check_tagged_hook_age import check_tagged_hooks - -TOOL = Path(__file__).resolve().parents[2] / "tools/check_tagged_hook_age.py" -CONFIG = ".pre-commit-config.yaml" -OLD = "a" * 40 -NEW = "b" * 40 - - -def _config(revision: str) -> str: - return f"repos:\n - repo: https://github.com/example/hook\n rev: {revision}\n hooks: []\n" - - -def _git(repository: Path, *arguments: str) -> str: - return subprocess.run( - ["git", *arguments], cwd=repository, text=True, capture_output=True, check=True - ).stdout.strip() - - -def _repository(tmp_path: Path, *, reintroduce: bool = False) -> tuple[Path, str]: - repository = tmp_path / "source" - repository.mkdir() - _git(repository, "init", "-q") - _git(repository, "config", "user.name", "Test") - _git(repository, "config", "user.email", "test@example.com") - (repository / CONFIG).write_text(_config(OLD)) - _git(repository, "add", CONFIG) - _git(repository, "commit", "-qm", "Initial hooks") - _git(repository, "tag", "v1.0.0") - (repository / CONFIG).write_text(_config(NEW)) - _git(repository, "commit", "-qam", "Change hooks") - introduction = _git(repository, "rev-parse", "HEAD") - if reintroduce: - (repository / CONFIG).write_text(_config(OLD)) - _git(repository, "commit", "-qam", "Remove new hook") - (repository / CONFIG).write_text(_config(NEW)) - _git(repository, "commit", "-qam", "Restore new hook directly") - _git(repository, "tag", "v1.1.0") - return repository, introduction - - -def _timestamp(days_ago: int) -> str: - return (datetime.now(UTC) - timedelta(days=days_ago)).isoformat().replace("+00:00", "Z") - - -def _expect_release(server: HTTPServer, tag: str = "v1.0.0") -> None: - server.expect_request("/api/v3/repos/tox-dev/fixture/releases", query_string="per_page=100").respond_with_json( - [{"tag_name": tag, "draft": False, "published_at": _timestamp(30)}] - ) - - -def _expect_pull(server: HTTPServer, introduction: str, *, merged_days: int, revision: str = NEW) -> None: - server.expect_request( - f"/api/v3/repos/tox-dev/fixture/commits/{introduction}/pulls", query_string="per_page=100" - ).respond_with_json( - [ - { - "number": 7, - "merge_commit_sha": introduction, - "merged_at": _timestamp(merged_days), - "created_at": _timestamp(20), - "base": {"repo": {"full_name": "tox-dev/fixture"}}, - "head": {"repo": {"full_name": "gaborbernat/fixture"}, "ref": "feature", "sha": "c" * 40}, - } - ] - ) - server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7/files", query_string="per_page=100").respond_with_json( - [{"filename": CONFIG}] - ) - if merged_days < 7: - server.expect_request( - f"/api/v3/repos/gaborbernat/fixture/contents/{CONFIG}", query_string=f"ref={'c' * 40}" - ).respond_with_json({"content": base64.b64encode(_config(revision).encode()).decode()}) - - -def _run(repository: Path, environment: dict[str, str], tag: str = "v1.1.0") -> subprocess.CompletedProcess[str]: - return subprocess.run( - [str(TOOL.parents[1] / ".release-tools/bin/python"), str(TOOL), "tox-dev/fixture", tag], - cwd=repository, - env=os.environ - | environment - | { - "PYTHONPATH": str(TOOL.parents[1] / "tests/coverage_bootstrap"), - "GW_COVERAGE_SITE_PACKAGES": str(Path(coverage.__file__).parent.parent), - }, - text=True, - capture_output=True, - check=False, - ) - - -@pytest.mark.parametrize( - ("merged_days", "head_revision", "accepted"), - [ - pytest.param(10, NEW, True, id="old-merged-pr-survives-deleted-fork"), - pytest.param(0, OLD, False, id="recent-pr-head-lacks-pair"), - ], -) -def test_tagged_hook_age_binds_introducing_pull_request( - tmp_path: Path, - github_api: tuple[HTTPServer, dict[str, str]], - merged_days: int, - head_revision: str, - accepted: bool, -) -> None: - server, environment = github_api - repository, introduction = _repository(tmp_path) - _expect_release(server) - _expect_pull(server, introduction, merged_days=merged_days, revision=head_revision) - - result = _run(repository, environment) - - assert (result.returncode == 0) is accepted, result.stderr - server.check_assertions() - - -def test_tagged_hook_age_rejects_direct_reintroduction( - tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - repository, introduction = _repository(tmp_path, reintroduce=True) - _expect_release(server) - server.expect_request( - f"/api/v3/repos/tox-dev/fixture/commits/{_git(repository, 'rev-parse', 'HEAD')}/pulls", - query_string="per_page=100", - ).respond_with_json([]) - - result = _run(repository, environment) - - assert result.returncode != 0 - assert "seven-day GitHub observation" in result.stderr - assert all(introduction not in request.path for request, _ in server.log) - - -@pytest.mark.parametrize( - ("observed_days", "observed_revision", "accepted"), - [ - pytest.param(10, NEW, True, id="old-run-with-pair"), - pytest.param(0, NEW, False, id="recent-run-with-pair"), - pytest.param(10, OLD, False, id="old-run-before-pair"), - ], -) -def test_tagged_hook_age_checks_observed_run_content( - tmp_path: Path, - github_api: tuple[HTTPServer, dict[str, str]], - observed_days: int, - observed_revision: str, - accepted: bool, -) -> None: - server, environment = github_api - repository, introduction = _repository(tmp_path) - _expect_release(server) - _expect_pull(server, introduction, merged_days=0) - server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs").respond_with_json( - { - "workflow_runs": [ - { - "event": "pull_request", - "head_repository": {"full_name": "gaborbernat/fixture"}, - "head_branch": "feature", - "head_sha": "d" * 40, - "created_at": _timestamp(observed_days), - "pull_requests": [], - } - ] - } - ) - if observed_days >= 7: - server.expect_request( - f"/api/v3/repos/gaborbernat/fixture/contents/{CONFIG}", query_string=f"ref={'d' * 40}" - ).respond_with_json({"content": base64.b64encode(_config(observed_revision).encode()).decode()}) - - result = _run(repository, environment) - - assert (result.returncode == 0) is accepted, result.stderr - server.check_assertions() - - -def test_tagged_hook_age_tries_another_observed_head_after_404( - tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - repository, introduction = _repository(tmp_path) - _expect_release(server) - _expect_pull(server, introduction, merged_days=0) - runs = [ - { - "event": "pull_request", - "head_repository": {"full_name": "gaborbernat/fixture"}, - "head_branch": "feature", - "head_sha": revision * 40, - "created_at": _timestamp(10), - "pull_requests": [], - } - for revision in ("e", "d") - ] - server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs").respond_with_json({"workflow_runs": runs}) - server.expect_request( - f"/api/v3/repos/gaborbernat/fixture/contents/{CONFIG}", query_string=f"ref={'e' * 40}" - ).respond_with_data("Not Found", status=404) - server.expect_request( - f"/api/v3/repos/gaborbernat/fixture/contents/{CONFIG}", query_string=f"ref={'d' * 40}" - ).respond_with_json({"content": base64.b64encode(_config(NEW).encode()).decode()}) - - result = _run(repository, environment) - - assert result.returncode == 0, result.stderr - server.check_assertions() - - -def test_tagged_hook_age_rejects_shallow_checkout(tmp_path: Path) -> None: - repository, _ = _repository(tmp_path) - shallow = tmp_path / "shallow" - subprocess.run( - ["git", "clone", "--quiet", "--depth", "1", repository.as_uri(), str(shallow)], - check=True, - capture_output=True, - ) - - result = _run(shallow, {}) - - assert result.returncode != 0 - assert "complete git history and tags" in result.stderr - - -@pytest.mark.parametrize( - ("previous_tag", "foreign_tag", "current_tag"), - [ - pytest.param("tox-toml-fmt/1.0.0", "pyproject-fmt/9.0.0", "tox-toml-fmt/1.1.0", id="package-stream"), - pytest.param("v1.0.0", "v1.1.0", "v1.2.0", id="side-branch-release"), - ], -) -def test_tagged_hook_age_ignores_other_release_lineage( - tmp_path: Path, - github_api: tuple[HTTPServer, dict[str, str]], - previous_tag: str, - foreign_tag: str, - current_tag: str, -) -> None: - server, environment = github_api - repository, introduction = _repository(tmp_path) - _git(repository, "tag", "-d", "v1.0.0", "v1.1.0") - _git(repository, "tag", previous_tag, "HEAD^") - if foreign_tag.startswith("v"): - main_branch = _git(repository, "branch", "--show-current") - _git(repository, "branch", "side", "HEAD^") - _git(repository, "checkout", "-q", "side") - (repository / CONFIG).write_text(_config(NEW)) - _git(repository, "commit", "-qam", "Add hook on side branch") - _git(repository, "tag", foreign_tag) - _git(repository, "checkout", "-q", main_branch) - else: - _git(repository, "tag", foreign_tag) - _git(repository, "tag", current_tag) - server.expect_request("/api/v3/repos/tox-dev/fixture/releases", query_string="per_page=100").respond_with_json( - [ - {"tag_name": foreign_tag, "draft": False, "published_at": _timestamp(20)}, - {"tag_name": previous_tag, "draft": False, "published_at": _timestamp(30)}, - ] - ) - server.expect_request( - f"/api/v3/repos/tox-dev/fixture/commits/{introduction}/pulls", query_string="per_page=100" - ).respond_with_json([]) - - result = _run(repository, environment, current_tag) - - assert result.returncode != 0 - assert "seven-day GitHub observation" in result.stderr - server.check_assertions() - - -def test_tagged_hook_age_ignores_public_tag_on_merged_side_branch( - tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - repository, side_commit = _repository(tmp_path) - main_branch = _git(repository, "branch", "--show-current") - _git(repository, "tag", "-d", "v1.1.0") - _git(repository, "branch", "side") - _git(repository, "reset", "--hard", "HEAD^") - _git(repository, "checkout", "-q", "side") - _git(repository, "tag", "v1.1.0") - _git(repository, "checkout", "-q", main_branch) - _git(repository, "merge", "--no-ff", "-m", "Merge side hooks", "side") - merge = _git(repository, "rev-parse", "HEAD") - _git(repository, "tag", "v1.2.0") - server.expect_request("/api/v3/repos/tox-dev/fixture/releases", query_string="per_page=100").respond_with_json( - [ - {"tag_name": "v1.1.0", "draft": False, "published_at": _timestamp(20)}, - {"tag_name": "v1.0.0", "draft": False, "published_at": _timestamp(30)}, - ] - ) - server.expect_request( - f"/api/v3/repos/tox-dev/fixture/commits/{merge}/pulls", query_string="per_page=100" - ).respond_with_json([]) - - result = _run(repository, environment, "v1.2.0") - - assert result.returncode != 0 - assert "seven-day GitHub observation" in result.stderr - assert all(side_commit not in request.path for request, _ in server.log) - - -@pytest.mark.parametrize( - ("tag", "accepted"), - [pytest.param("v1.1.0", True, id="dispatched-tag"), pytest.param("v1.0.0", False, id="wrong-checkout")], -) -def test_dispatched_release_requires_checkout_at_tag(tmp_path: Path, tag: str, accepted: bool) -> None: - repository, _ = _repository(tmp_path) - action = yaml.safe_load((TOOL.parents[1] / "verify-frozen-hooks/action.yml").read_text()) - step = next(item for item in action["runs"]["steps"] if item.get("name") == "Check tagged hook revision provenance") - assert step["if"] == "inputs.tag != '' || startsWith(github.ref, 'refs/tags/')" - fake_bin = tmp_path / "bin" - fake_bin.mkdir() - fake_uv = fake_bin / "uv" - fake_uv.write_text("#!/bin/sh\nexit 0\n") - fake_uv.chmod(0o755) - - result = subprocess.run( - ["bash", "-e", "-o", "pipefail", "-c", step["run"]], - cwd=repository, - env=os.environ - | { - "PATH": f"{fake_bin}:{os.environ['PATH']}", - "TAG": tag, - "RUNNER_TEMP": str(tmp_path), - "GITHUB_ACTION_PATH": str(TOOL.parent), - "GITHUB_REPOSITORY": "tox-dev/fixture", - }, - text=True, - capture_output=True, - check=False, - ) - - assert (result.returncode == 0) is accepted, result.stderr - - -@pytest.mark.parametrize( - ("repository", "tag", "config"), - [ - pytest.param("", "v1.0.0", CONFIG, id="missing-repository"), - pytest.param("tox-dev/fixture", "", CONFIG, id="missing-tag"), - pytest.param("tox-dev/fixture", "v1.0.0", "/tmp/hooks.yaml", id="absolute-config"), - pytest.param("tox-dev/fixture", "v1.0.0", "../hooks.yaml", id="traversing-config"), - ], -) -def test_tagged_hook_age_rejects_invalid_arguments(repository: str, tag: str, config: str) -> None: - with pytest.raises(ValueError, match="relative config path"): - check_tagged_hooks(repository, tag, config) - - -@pytest.mark.parametrize( - ("content", "error"), - [ - pytest.param("[]\n", "repository list", id="non-mapping"), - pytest.param("repos: null\n", "repository list", id="missing-list"), - pytest.param("repos:\n - 1\n", "needs a URL", id="non-mapping-repository"), - pytest.param("repos:\n - repo: 1\n", "needs a URL", id="invalid-url"), - pytest.param("repos:\n - repo: https://github.com/example/hook\n", "needs a revision", id="missing-rev"), - ], -) -def test_tagged_hook_age_rejects_malformed_configuration( - tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]], content: str, error: str -) -> None: - server, environment = github_api - repository, _ = _repository(tmp_path) - _expect_release(server) - (repository / CONFIG).write_text(content) - - result = _run(repository, environment) - - assert result.returncode != 0 - assert error in result.stderr - - -def test_tagged_hook_age_accepts_unchanged_revision( - tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - repository, _ = _repository(tmp_path) - _git(repository, "tag", "-d", "v1.1.0") - _git(repository, "reset", "--hard", "HEAD^") - _git(repository, "commit", "--allow-empty", "-qm", "No hook changes") - _git(repository, "tag", "v1.1.0") - _expect_release(server) - - result = _run(repository, environment) - - assert result.returncode == 0, result.stderr - - -def test_tagged_hook_age_accepts_local_only_configuration( - tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - repository, _ = _repository(tmp_path) - _expect_release(server) - (repository / CONFIG).write_text("repos:\n - repo: local\n hooks: []\n - repo: meta\n hooks: []\n") - - result = _run(repository, environment) - - assert result.returncode == 0, result.stderr - - -def test_tagged_hook_age_rejects_invalid_tag_and_api_failure( - tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - repository, _ = _repository(tmp_path) - _expect_release(server) - - invalid = _run(repository, environment, "not-a-release") - - assert invalid.returncode != 0 - assert "cannot select prior release tag" in invalid.stderr - server.clear() - server.expect_request("/api/v3/repos/tox-dev/fixture/releases", query_string="per_page=100").respond_with_data( - "server error", status=503 - ) - - unavailable = _run(repository, environment) - - assert unavailable.returncode != 0 - assert "GitHub API request failed" in unavailable.stderr - - -def test_tagged_hook_age_rejects_missing_git_repository(tmp_path: Path) -> None: - result = _run(tmp_path, {}) - - assert result.returncode != 0 - assert "git rev-parse" in result.stderr - - -def test_tagged_hook_age_requires_proof_for_first_release( - tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]] -) -> None: - server, environment = github_api - repository, introduction = _repository(tmp_path) - server.expect_request("/api/v3/repos/tox-dev/fixture/releases", query_string="per_page=100").respond_with_json([]) - for commit in (_git(repository, "rev-parse", "HEAD^"), introduction): - server.expect_request( - f"/api/v3/repos/tox-dev/fixture/commits/{commit}/pulls", query_string="per_page=100" - ).respond_with_json([]) - - result = _run(repository, environment) - - assert result.returncode != 0 - assert "seven-day GitHub observation" in result.stderr - server.check_assertions() - - -@pytest.mark.parametrize("case", ["unrelated-pull", "unchanged-file", "deleted-fork"], ids=str) -def test_tagged_hook_age_rejects_unproven_introducing_pull( - tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]], case: str -) -> None: - server, environment = github_api - repository, introduction = _repository(tmp_path) - _expect_release(server) - pull = { - "number": 7, - "merge_commit_sha": "0" * 40 if case == "unrelated-pull" else introduction, - "merged_at": _timestamp(0), - "created_at": _timestamp(20), - "base": {"repo": {"full_name": "tox-dev/fixture"}}, - "head": { - "repo": None if case == "deleted-fork" else {"full_name": "gaborbernat/fixture"}, - "ref": "feature", - "sha": "c" * 40, - }, - } - server.expect_request( - f"/api/v3/repos/tox-dev/fixture/commits/{introduction}/pulls", query_string="per_page=100" - ).respond_with_json([pull]) - if case != "unrelated-pull": - server.expect_request( - "/api/v3/repos/tox-dev/fixture/pulls/7/files", query_string="per_page=100" - ).respond_with_json([{"filename": "README.md" if case == "unchanged-file" else CONFIG}]) - - result = _run(repository, environment) - - assert result.returncode != 0 - assert "seven-day GitHub observation" in result.stderr - server.check_assertions() - - -@pytest.mark.parametrize("case", ["other-pull", "server-error"], ids=str) -def test_tagged_hook_age_rejects_untrusted_or_unavailable_run( - tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]], case: str -) -> None: - server, environment = github_api - repository, introduction = _repository(tmp_path) - _expect_release(server) - _expect_pull(server, introduction, merged_days=0) - server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs").respond_with_json( - { - "workflow_runs": [ - { - "event": "pull_request", - "head_repository": {"full_name": "gaborbernat/fixture"}, - "head_branch": "feature", - "head_sha": "d" * 40, - "created_at": _timestamp(10), - "pull_requests": [{"number": 8}] if case == "other-pull" else [], - } - ] - } - ) - if case == "server-error": - server.expect_request( - f"/api/v3/repos/gaborbernat/fixture/contents/{CONFIG}", query_string=f"ref={'d' * 40}" - ).respond_with_data("server error", status=503) - - result = _run(repository, environment) - - assert result.returncode != 0 - assert ("seven-day GitHub observation" if case == "other-pull" else "GitHub API request failed") in result.stderr - server.check_assertions() diff --git a/tests/conftest.py b/tests/conftest.py index 99cf9df..671b05d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -72,7 +72,6 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: "publish-jetbrains-marketplace", "sign-jetbrains-plugin", "publish-post-release-pr", - "verify-frozen-hooks", ): action = yaml.safe_load((source / action_name / "action.yml").read_text()) for index, step in enumerate(action["runs"]["steps"]): @@ -464,11 +463,6 @@ def _condition( return any(_condition(part, environment, inputs, step_outputs) for part in expression.split(" || ")) if " && " in expression: return all(_condition(part, environment, inputs, step_outputs) for part in expression.split(" && ")) - for function in ("startsWith", "endsWith"): - if expression.startswith(f"{function}(") and expression.endswith(")"): - subject, affix = expression.removeprefix(f"{function}(").removesuffix(")").split(", ", 1) - value = _expression(subject, environment, inputs, step_outputs) - return value.startswith(affix.strip("'")) if function == "startsWith" else value.endswith(affix.strip("'")) for operator in (" == ", " != "): if operator in expression: left, right = expression.split(operator, 1) @@ -554,15 +548,6 @@ def _expression( return expression[1:-1] if " == " in expression or " != " in expression: return str(_condition(expression, environment, inputs, step_outputs)).lower() - if " || " in expression: - return next( - ( - value - for part in expression.split(" || ") - if (value := _expression(part, environment, inputs, step_outputs)) - ), - "", - ) if expression.startswith("inputs."): return inputs[expression.removeprefix("inputs.")] if expression.startswith("steps."): @@ -570,10 +555,6 @@ def _expression( return step_outputs.get(step_id, {}).get(output_name, "") if expression == "runner.temp": return environment["RUNNER_TEMP"] - if expression == "github.ref": - return environment.get("GITHUB_REF", "") - if expression == "github.ref_name": - return environment.get("GITHUB_REF_NAME", "") if expression == "github.repository": return environment["GITHUB_REPOSITORY"] if expression == "github.base_ref": diff --git a/tests/check_hook_update_cooldown/test_workflow.py b/tests/cooled_prek/test_cooled_prek.py similarity index 81% rename from tests/check_hook_update_cooldown/test_workflow.py rename to tests/cooled_prek/test_cooled_prek.py index 6cdb55d..25e60ce 100644 --- a/tests/check_hook_update_cooldown/test_workflow.py +++ b/tests/cooled_prek/test_cooled_prek.py @@ -8,16 +8,6 @@ import yaml -def test_tagged_source_checks_frozen_hooks_before_prek() -> None: - root = Path(__file__).resolve().parents[2] - prepare = yaml.load((root / ".github/workflows/prepare-towncrier-release.yaml").read_text(), Loader=yaml.BaseLoader) - tagged = yaml.load((root / "check-tagged-source/action.yml").read_text(), Loader=yaml.BaseLoader) - - for steps in (prepare["jobs"]["assemble"]["steps"], tagged["runs"]["steps"]): - frozen = next(index for index, step in enumerate(steps) if step.get("uses") == "$/verify-frozen-hooks") - assert frozen < next(index for index, step in enumerate(steps) if "prek" in step.get("run", "")) - - def test_every_hook_run_uses_the_root_config() -> None: root = Path(__file__).resolve().parents[2] runs = [ diff --git a/tests/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py index cccde66..84d7024 100644 --- a/tests/prepare_towncrier/test_prepare.py +++ b/tests/prepare_towncrier/test_prepare.py @@ -383,3 +383,26 @@ def _author_lookup(github_api: tuple[HTTPServer, dict[str, str]], number: int) - _, environment = github_api environment["TEST_PULL_NUMBER"] = str(number) return environment + + +@pytest.mark.parametrize( + "path", + [ + pytest.param("prek.toml", id="root-prek-toml"), + pytest.param("sub/prek.toml", id="nested-prek-toml"), + pytest.param(".pre-commit-config.yml", id="root-yml"), + pytest.param("sub/.pre-commit-config.yaml", id="nested-yaml"), + ], +) +def test_prepare_rejects_other_hook_configuration( + repository: Path, tmp_path: Path, action_scripts: Path, path: str +) -> None: + (repository / path).parent.mkdir(parents=True, exist_ok=True) + (repository / path).write_text("repos: []\n") + run(repository, "git", "add", path) + run(repository, "git", "commit", "-m", "Add another hook configuration") + + result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0") + + assert result.returncode != 0 + assert f"only in the root .pre-commit-config.yaml: {path}" in result.stderr diff --git a/tests/validate_release_patch/test_validate.py b/tests/validate_release_patch/test_validate.py index 76844a3..3551d33 100644 --- a/tests/validate_release_patch/test_validate.py +++ b/tests/validate_release_patch/test_validate.py @@ -351,3 +351,27 @@ def test_validate_rejects_prek_changes(repository: Path, tmp_path: Path, action_ ) assert result.returncode != 0 assert "prek failed" in result.stderr + + +@pytest.mark.parametrize( + "path", + [ + pytest.param("prek.toml", id="root-prek-toml"), + pytest.param("sub/.pre-commit-config.yaml", id="nested-yaml"), + ], +) +def test_validate_rejects_other_hook_configuration( + repository: Path, tmp_path: Path, action_scripts: Path, path: str +) -> None: + assert action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0").returncode == 0 + run(repository, "git", "reset", "--hard", "HEAD") + (repository / path).parent.mkdir(parents=True, exist_ok=True) + (repository / path).write_text("repos: []\n") + run(repository, "git", "add", path) + run(repository, "git", "commit", "-m", "Add another hook configuration") + run(repository, "git", "push", "origin", "main") + + result = action(repository, tmp_path, action_scripts, "validate-release-patch", version="0.2.0") + + assert result.returncode != 0 + assert f"only in the root .pre-commit-config.yaml: {path}" in result.stderr diff --git a/tests/verify_frozen_hooks/test_frozen_hook_action.py b/tests/verify_frozen_hooks/test_frozen_hook_action.py deleted file mode 100644 index dd52974..0000000 --- a/tests/verify_frozen_hooks/test_frozen_hook_action.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Final - -import pytest -from conftest import action, run - -_LOCAL_HOOK: Final = ( - "repos:\n - repo: local\n hooks:\n - id: noop\n name: noop\n entry: 'true'\n" - " language: system\n" -) - - -def _repository(tmp_path: Path, config: str, *extra: str) -> Path: - repository = tmp_path / "source" - repository.mkdir() - run(repository, "git", "init", "-q") - (repository / ".pre-commit-config.yaml").write_text(config) - for path in extra: - (repository / path).parent.mkdir(parents=True, exist_ok=True) - (repository / path).write_text(_LOCAL_HOOK) - run(repository, "git", "add", ".") - run(repository, "git", "-c", "user.name=Test", "-c", "user.email=test@example.org", "commit", "-qm", "Hooks") - return repository - - -def test_verify_frozen_hooks_accepts_root_configuration(tmp_path: Path, action_scripts: Path) -> None: - repository = _repository(tmp_path, _LOCAL_HOOK) - - result = action(repository, tmp_path, action_scripts, "verify-frozen-hooks", version="") - - assert result.returncode == 0, result.stderr - - -@pytest.mark.parametrize( - "path", - [ - pytest.param("prek.toml", id="root-prek-toml"), - pytest.param("sub/prek.toml", id="nested-prek-toml"), - pytest.param(".pre-commit-config.yml", id="root-yml"), - pytest.param("sub/.pre-commit-config.yaml", id="nested-yaml"), - ], -) -def test_verify_frozen_hooks_rejects_other_configuration(tmp_path: Path, action_scripts: Path, path: str) -> None: - repository = _repository(tmp_path, _LOCAL_HOOK, path) - - result = action(repository, tmp_path, action_scripts, "verify-frozen-hooks", version="") - - assert result.returncode != 0 - assert f"only in the root .pre-commit-config.yaml: {path}" in result.stderr - - -def test_verify_frozen_hooks_rejects_movable_revision(tmp_path: Path, action_scripts: Path) -> None: - repository = _repository( - tmp_path, "repos:\n - repo: https://github.com/example/hooks\n rev: v1\n hooks: []\n" - ) - - result = action(repository, tmp_path, action_scripts, "verify-frozen-hooks", version="") - - assert result.returncode != 0 - assert "full lowercase commit SHA" in result.stderr - - -def test_verify_frozen_hooks_rejects_missing_managed_pins(tmp_path: Path, action_scripts: Path) -> None: - repository = _repository( - tmp_path, - "repos:\n - repo: local\n hooks:\n - id: fetch\n name: fetch\n entry: 'true'\n" - " language: python\n additional_dependencies: [requests==2.31.0]\n", - ) - - result = action(repository, tmp_path, action_scripts, "verify-frozen-hooks", version="") - - assert result.returncode != 0 - assert "pins do not match the resolved closure" in result.stderr - - -def test_verify_frozen_hooks_proves_tagged_revisions_before_building_hooks( - tmp_path: Path, action_scripts: Path -) -> None: - repository = _repository( - tmp_path, - "repos:\n - repo: local\n hooks:\n - id: fetch\n name: fetch\n entry: 'true'\n" - " language: python\n additional_dependencies: [requests==2.31.0]\n", - ) - - result = action( - repository, - tmp_path, - action_scripts, - "verify-frozen-hooks", - version="", - GITHUB_REF="refs/tags/v1.0.0", - GITHUB_REF_NAME="v1.0.0", - ) - - assert result.returncode != 0 - assert "pins do not match the resolved closure" not in result.stderr diff --git a/tools/check_frozen_hooks.py b/tools/check_frozen_hooks.py deleted file mode 100644 index c3587ab..0000000 --- a/tools/check_frozen_hooks.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Require immutable revisions for external pre-commit hooks.""" - -from __future__ import annotations - -import argparse -import re -from pathlib import Path - -from yaml import YAMLError, safe_load - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("config", type=Path) - args = parser.parse_args() - try: - check_frozen(args.config) - except (OSError, TypeError, ValueError, YAMLError) as error: - parser.error(str(error)) - - -def check_frozen(path: Path) -> None: - config = safe_load(path.read_text(encoding="utf-8")) - if not isinstance(config, dict) or not isinstance(config.get("repos"), list): - raise TypeError("hook configuration must contain a repository list") - for repository in config["repos"]: - if not isinstance(repository, dict) or not isinstance(repository.get("repo"), str): - raise TypeError("hook repository must be a mapping with a URL") - hooks = repository.get("hooks", []) - if not isinstance(hooks, list) or not all(isinstance(hook, dict) for hook in hooks): - raise TypeError(f"hooks must be a list of mappings: {repository['repo']}") - for hook in hooks: - # release-creator pins and cools only Python hook dependencies; other ecosystems would install unpinned. - if hook.get("additional_dependencies") and hook.get("language") not in {None, "python"}: - raise ValueError(f"only Python hooks may declare additional_dependencies: {hook.get('id')}") - if repository["repo"] in {"local", "meta"}: - continue - revision = repository.get("rev") - if not isinstance(revision, str) or re.fullmatch(r"[0-9a-f]{40}", revision) is None: - raise ValueError(f"external hook revision must be a full lowercase commit SHA: {repository['repo']}") - - -if __name__ == "__main__": - main() - - -__all__ = [ - "check_frozen", - "main", -] diff --git a/tools/check_tagged_hook_age.py b/tools/check_tagged_hook_age.py deleted file mode 100644 index 00ef134..0000000 --- a/tools/check_tagged_hook_age.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Require GitHub-observed age for hook revisions added since the last release.""" - -from __future__ import annotations - -import argparse -import base64 -import json -import subprocess -import sys -from datetime import UTC, datetime, timedelta -from pathlib import Path -from typing import Any - -from yaml import YAMLError, safe_load - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("repository") - parser.add_argument("tag") - parser.add_argument("--config", default=".pre-commit-config.yaml") - try: - args = parser.parse_args() - check_tagged_hooks(args.repository, args.tag, args.config) - except (OSError, RuntimeError, TypeError, ValueError, YAMLError, json.JSONDecodeError) as error: - parser.error(str(error)) - - -def check_tagged_hooks(repository: str, tag: str, config: str) -> None: - if not repository or not tag or Path(config).is_absolute() or ".." in Path(config).parts: - raise ValueError("tagged hook proof needs a repository, tag, and relative config path") - if _git("rev-parse", "--is-shallow-repository") != "false": - raise ValueError("tagged hook proof needs complete git history and tags") - head = _git("rev-parse", "HEAD") - previous = _previous_release(repository, head, tag) - current = _pairs(Path(config).read_text(encoding="utf-8")) - pending = current - (_pairs_at(previous, config) if previous else set()) - if not pending: - return - revision_range = f"{previous}..HEAD" if previous else "HEAD" - introductions: dict[tuple[str, str], str] = {} - for commit in _git("rev-list", "--first-parent", "--reverse", revision_range, "--", config).splitlines(): - parent = _git("rev-list", "--parents", "-n", "1", commit).split()[1:2] - introduced = (_pairs_at(commit, config) - (_pairs_at(parent[0], config) if parent else set())) & pending - introductions.update(dict.fromkeys(introduced, commit)) - for commit in set(introductions.values()): - pending -= _proven_pairs( - repository, config, commit, {pair for pair, change in introductions.items() if change == commit} - ) - if not pending: - return - raise ValueError( - f"hook revisions lack a seven-day GitHub observation: {', '.join(sorted(f'{repo}@{rev}' for repo, rev in pending))}" - ) - - -def _previous_release(repository: str, head: str, tag: str) -> str | None: - published = { - release["tag_name"] - for release in _api(f"repos/{repository}/releases?per_page=100") - if not release.get("draft") and release.get("published_at") and isinstance(release.get("tag_name"), str) - } - lineage = set(_git("rev-list", "--first-parent", head).splitlines()) - tags = _git("tag", "--list") - while previous := _previous_tag(tag, tags): - commit = _git_optional("rev-parse", "--verify", f"refs/tags/{previous}^{{commit}}") - if previous in published and commit in lineage and commit != head: - return previous - tag = previous - return None - - -def _previous_tag(tag: str, tags: str) -> str: - command = [sys.executable, "-m", "release_creator", "tags", tag, "--previous"] - if "/" in tag: - command.extend(("--package", tag.split("/", 1)[0])) - result = subprocess.run(command, input=tags, text=True, capture_output=True, check=False) - if result.returncode: - raise RuntimeError(f"cannot select prior release tag: {result.stderr.strip()}") - return result.stdout.strip() - - -def _pairs_at(revision: str, config: str) -> set[tuple[str, str]]: - content = _git_optional("show", f"{revision}:{config}") - return _pairs(content) if content is not None else set() - - -def _pairs(content: str) -> set[tuple[str, str]]: - root = safe_load(content) - if not isinstance(root, dict) or not isinstance(root.get("repos"), list): - raise TypeError("hook configuration needs a repository list") - pairs: set[tuple[str, str]] = set() - for item in root["repos"]: - if not isinstance(item, dict) or not isinstance(item.get("repo"), str): - raise TypeError("hook repository needs a URL") - if item["repo"] not in {"local", "meta"}: - if not isinstance(item.get("rev"), str): - raise TypeError("external hook repository needs a revision") - pairs.add((item["repo"], item["rev"])) - return pairs - - -def _proven_pairs(repository: str, config: str, commit: str, introduced: set[tuple[str, str]]) -> set[tuple[str, str]]: - proven: set[tuple[str, str]] = set() - for pull in _api(f"repos/{repository}/commits/{commit}/pulls?per_page=100"): - if ( - pull.get("merge_commit_sha") != commit - or not pull.get("merged_at") - or pull.get("base", {}).get("repo", {}).get("full_name") != repository - ): - continue - number = pull["number"] - if config not in {item["filename"] for item in _api(f"repos/{repository}/pulls/{number}/files?per_page=100")}: - continue - merged_at = datetime.fromisoformat(pull["merged_at"]) - if merged_at <= datetime.now(UTC) - timedelta(days=7): - proven |= introduced - continue - head = pull["head"] - if not isinstance(head.get("repo"), dict) or not isinstance(head["repo"].get("full_name"), str): - continue - head_repository = head["repo"]["full_name"] - encoded = _api_object(f"repos/{head_repository}/contents/{config}?ref={head['sha']}")["content"] - for pair in _pairs(base64.b64decode(encoded).decode("utf-8")) & introduced: - if _observed_pair(repository, config, pair, pull): - proven.add(pair) - return proven - - -def _observed_pair(repository: str, config: str, pair: tuple[str, str], pull: dict[str, Any]) -> bool: - head = pull["head"] - source = head["repo"]["full_name"] - opened = datetime.fromisoformat(pull["created_at"]) - cutoff = datetime.now(UTC) - timedelta(days=7) - for run in _api( - f"repos/{repository}/actions/runs", - event="pull_request", - branch=head["ref"], - created=f"<{cutoff.isoformat(timespec='seconds').replace('+00:00', 'Z')}", - per_page="100", - ): - if ( - run["event"] != "pull_request" - or not isinstance(run.get("head_repository"), dict) - or run["head_repository"].get("full_name") != source - or run["head_branch"] != head["ref"] - or datetime.fromisoformat(run["created_at"]) < opened - or datetime.fromisoformat(run["created_at"]) > cutoff - ): - continue - if not isinstance(run.get("pull_requests"), list) or ( - run["pull_requests"] and not any(item["number"] == pull["number"] for item in run["pull_requests"]) - ): - continue - try: - encoded = _api_object(f"repos/{source}/contents/{config}?ref={run['head_sha']}")["content"] - except RuntimeError as error: - if "HTTP 404" in str(error): - continue - raise - if pair in _pairs(base64.b64decode(encoded).decode("utf-8")): - return True - return False - - -def _api(path: str, **fields: str) -> list[dict[str, Any]]: - command = ["gh", "api", "--paginate", "--slurp", "-X", "GET", path] - for name, value in fields.items(): - command.extend(("-f", f"{name}={value}")) - result = subprocess.run(command, text=True, capture_output=True, check=False) - if result.returncode: - raise RuntimeError(f"GitHub API request failed for {path}: {result.stderr.strip()}") - return [ - item - for page in json.loads(result.stdout) - for item in (page if isinstance(page, list) else page.get("workflow_runs", page.get("jobs", []))) - ] - - -def _api_object(path: str) -> dict[str, Any]: - result = subprocess.run(["gh", "api", path], text=True, capture_output=True, check=False) - if result.returncode: - raise RuntimeError(f"GitHub API request failed for {path}: {result.stderr.strip()}") - return json.loads(result.stdout) - - -def _git(*arguments: str) -> str: - result = subprocess.run(["git", *arguments], text=True, capture_output=True, check=False) - if result.returncode: - raise RuntimeError(f"git {' '.join(arguments)} failed: {result.stderr.strip()}") - return result.stdout.strip() - - -def _git_optional(*arguments: str) -> str | None: - result = subprocess.run(["git", *arguments], text=True, capture_output=True, check=False) - return result.stdout.strip() if result.returncode == 0 else None - - -if __name__ == "__main__": - main() - - -__all__ = [ - "check_tagged_hooks", - "main", -] diff --git a/validate-release-patch/action.yml b/validate-release-patch/action.yml index 7978932..7f824c3 100644 --- a/validate-release-patch/action.yml +++ b/validate-release-patch/action.yml @@ -120,6 +120,17 @@ runs: VERSION: ${{ inputs.version }} CHANGELOG: ${{ steps.config.outputs.changelog }} run: release-creator notes --changelog "$CHANGELOG" --tag "$VERSION" --repository "$GITHUB_REPOSITORY" >/dev/null + - name: Reject other hook configurations + if: inputs.checks == 'true' + shell: bash + run: | + set -euo pipefail + others=$(git ls-files -- ':(glob)**/prek.toml' ':(glob)**/.pre-commit-config.yml' \ + ':(glob)*/**/.pre-commit-config.yaml') + if [[ -n "$others" ]]; then + echo "::error::hooks must be configured only in the root .pre-commit-config.yaml: ${others//$'\n'/, }" >&2 + exit 1 + fi - name: Run prek on release patch if: inputs.checks == 'true' shell: bash diff --git a/verify-frozen-hooks/action.yml b/verify-frozen-hooks/action.yml deleted file mode 100644 index 2224ecd..0000000 --- a/verify-frozen-hooks/action.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Verify frozen hooks -description: Check immutable revisions and the complete cooled Python dependency closure -inputs: - tag: - description: Explicit release tag when the workflow is dispatched from a branch - default: "" -runs: - using: composite - steps: - - name: Reject other hook configurations - shell: bash - run: | - set -euo pipefail - others=$(git ls-files -- ':(glob)**/prek.toml' ':(glob)**/.pre-commit-config.yml' \ - ':(glob)*/**/.pre-commit-config.yaml') - if [[ -n "$others" ]]; then - echo "::error::hooks must be configured only in the root .pre-commit-config.yaml: ${others//$'\n'/, }" >&2 - exit 1 - fi - - uses: $/install-locked-tools - with: - groups: release-notes - lock-source: shared - venv: ${{ runner.temp }}/frozen-hook-tools - - name: Check external hook revisions - shell: bash - run: | - uv run --no-project --python "$RUNNER_TEMP/frozen-hook-tools" python \ - "$GITHUB_ACTION_PATH/../tools/check_frozen_hooks.py" .pre-commit-config.yaml - - name: Check tagged hook revision provenance - if: inputs.tag != '' || startsWith(github.ref, 'refs/tags/') - shell: bash - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ inputs.tag || github.ref_name }} - run: | - set -euo pipefail - test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$(git rev-parse HEAD)" - uv run --no-project --python "$RUNNER_TEMP/frozen-hook-tools" python \ - "$GITHUB_ACTION_PATH/../tools/check_tagged_hook_age.py" \ - "$GITHUB_REPOSITORY" "$TAG" - - name: Check Python hook dependencies - shell: bash - run: uv run --no-project --python "$RUNNER_TEMP/frozen-hook-tools" release-creator hooks --check From 6a65784a9926c54e2779556f8c68a15c7086383d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 12:41:25 -0700 Subject: [PATCH 03/35] =?UTF-8?q?=F0=9F=94=A5=20feat(deps)!:=20drop=20the?= =?UTF-8?q?=20weekly=20hook=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the generate-hooks job of upgrade-release-tools.yaml and the hook patch handling in its writer, which now checks and applies only the lock patch. --- .github/workflows/upgrade-release-tools.yaml | 97 +++----------------- README.md | 35 +++---- changelog.d/+frozen-hooks.breaking.md | 4 +- changelog.d/+weekly-hooks.breaking.md | 3 + tests/upgrade_release_tools/test_upgrade.py | 96 +++++-------------- 5 files changed, 52 insertions(+), 183 deletions(-) create mode 100644 changelog.d/+weekly-hooks.breaking.md diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index 114db8c..c3d3e9c 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -84,60 +84,9 @@ jobs: path: ${{ runner.temp }}/lock.patch if-no-files-found: error retention-days: 7 - generate-hooks: - runs-on: ubuntu-26.04 - env: - UV_EXCLUDE_NEWER: "" - UV_EXCLUDE_NEWER_PACKAGE: "" - permissions: - contents: read - outputs: - changed: ${{ steps.hooks.outputs.changed }} - artifact-id: ${{ steps.upload.outputs.artifact-id }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 1 - persist-credentials: false - - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - with: - version: latest-known - python-version: "3.15" - enable-cache: false - - uses: $/install-locked-tools - with: - groups: release-hooks - lock-source: shared - venv: ${{ runner.temp }}/hook-tools - - name: Refresh frozen hook revisions - run: >- - uv run --no-project --python "$RUNNER_TEMP/hook-tools" prek update --config .pre-commit-config.yaml --freeze --cooldown-days 7 --no-progress - - name: Refresh exact hook dependencies - run: >- - uv run --no-project --python "$RUNNER_TEMP/hook-tools" release-creator hooks - - id: hooks - name: Write hook patch - run: | - set -euo pipefail - if git diff --quiet -- .pre-commit-config.yaml; then - echo 'changed=false' >>"$GITHUB_OUTPUT" - exit 0 - fi - echo 'changed=true' >>"$GITHUB_OUTPUT" - git diff --binary -- .pre-commit-config.yaml >"$RUNNER_TEMP/hooks.patch" - - id: upload - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - if: steps.hooks.outputs.changed == 'true' - with: - name: release-tools-hooks - path: ${{ runner.temp }}/hooks.patch - if-no-files-found: error - retention-days: 7 publish: - needs: [generate-lock, generate-hooks] - if: | - (needs.generate-lock.outputs.changed == 'true' || needs.generate-hooks.outputs.changed == 'true') && - github.ref == 'refs/heads/main' + needs: generate-lock + if: needs.generate-lock.outputs.changed == 'true' && github.ref == 'refs/heads/main' runs-on: ubuntu-26.04 environment: release-auth permissions: @@ -149,21 +98,13 @@ jobs: fetch-depth: 0 persist-credentials: false - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - if: needs.generate-lock.outputs.changed == 'true' with: artifact-ids: ${{ needs.generate-lock.outputs.artifact-id }} path: ${{ runner.temp }}/release-tools-lock - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - if: needs.generate-hooks.outputs.changed == 'true' - with: - artifact-ids: ${{ needs.generate-hooks.outputs.artifact-id }} - path: ${{ runner.temp }}/release-tools-hooks - name: Check artifact patch paths env: ADDITIONAL_LOCK: ${{ inputs.additional-lock }} KEEP_UV_LOCK: ${{ inputs.keep-uv-lock }} - LOCK_CHANGED: ${{ needs.generate-lock.outputs.changed }} - HOOKS_CHANGED: ${{ needs.generate-hooks.outputs.changed }} run: | set -euo pipefail if [[ -n "$ADDITIONAL_LOCK" && @@ -171,33 +112,19 @@ jobs: echo "::error::invalid additional lock: $ADDITIONAL_LOCK" >&2 exit 1 fi - for artifact in lock hooks; do - if [[ "$artifact" == lock ]]; then - [[ "$LOCK_CHANGED" == true ]] || continue - patch="$RUNNER_TEMP/release-tools-lock/lock.patch" - else - [[ "$HOOKS_CHANGED" == true ]] || continue - patch="$RUNNER_TEMP/release-tools-hooks/hooks.patch" + GIT_INDEX_FILE="$RUNNER_TEMP/lock.index" git read-tree HEAD + GIT_INDEX_FILE="$RUNNER_TEMP/lock.index" git apply --cached "$RUNNER_TEMP/release-tools-lock/lock.patch" + GIT_INDEX_FILE="$RUNNER_TEMP/lock.index" \ + git diff --cached --name-only --no-renames -z >"$RUNNER_TEMP/lock-paths" + while IFS= read -r -d '' path; do + if [[ "$path" != pylock.toml && "$path" != pyproject.toml && "$path" != "$ADDITIONAL_LOCK" && + ("$path" != uv.lock || "$KEEP_UV_LOCK" != true) ]]; then + echo "::error::unexpected lock patch path: $path" >&2 + exit 1 fi - GIT_INDEX_FILE="$RUNNER_TEMP/$artifact.index" git read-tree HEAD - GIT_INDEX_FILE="$RUNNER_TEMP/$artifact.index" git apply --cached "$patch" - GIT_INDEX_FILE="$RUNNER_TEMP/$artifact.index" \ - git diff --cached --name-only --no-renames -z >"$RUNNER_TEMP/$artifact-paths" - while IFS= read -r -d '' path; do - if [[ "$artifact" == lock && "$path" != pylock.toml && "$path" != pyproject.toml && - "$path" != "$ADDITIONAL_LOCK" && ("$path" != uv.lock || "$KEEP_UV_LOCK" != true) ]] || - [[ "$artifact" == hooks && "$path" != .pre-commit-config.yaml ]]; then - echo "::error::unexpected $artifact patch path: $path" >&2 - exit 1 - fi - done <"$RUNNER_TEMP/$artifact-paths" - done + done <"$RUNNER_TEMP/lock-paths" - name: Apply lock patch - if: needs.generate-lock.outputs.changed == 'true' run: git apply --index "$RUNNER_TEMP/release-tools-lock/lock.patch" - - name: Apply hook patch - if: needs.generate-hooks.outputs.changed == 'true' - run: git apply --index "$RUNNER_TEMP/release-tools-hooks/hooks.patch" - name: Check dependency patch file mode run: | set -euo pipefail diff --git a/README.md b/README.md index c39390d..df9319b 100644 --- a/README.md +++ b/README.md @@ -90,19 +90,17 @@ changelog: The Action expects a pull request base ref and a Markdown or reStructuredText changelog. The caller should also run on `labeled` and `unlabeled` events, so a label added after opening the PR updates the required check. -## Refresh release tools and hooks - -Call `upgrade-release-tools.yaml` from a scheduled or manual workflow. Its read-only jobs refresh the universal -`pylock.toml` and the frozen pre-commit hooks in parallel. `release-creator locks` exports runtime dependencies, all -extras, and all dependency groups with a seven-day package cutoff. Reviewed exceptions live in -`[tool.release-creator.locks.exclude-newer-package]`. Both lock checks and weekly refresh install uv from the shared -`release-resolver` group, so a setup-uv default change cannot alter the generated lock. In github-workflows, the weekly -updater also refreshes this direct pin and the lock together; consumer locks use their pinned shared resolver. The hook -updater applies the same cooldown to hook revisions and their `additional_dependencies`. Each job uploads a patch. The -lock patch may change `pylock.toml`, an explicitly configured secondary Nab lock, and `pyproject.toml` for expired -reviewed cutoffs. The hook patch may change only `.pre-commit-config.yaml`. The writer checks each patch before applying -it and verifies cutoff removals before minting the App token. It then opens or updates one dependency PR from -`automation/update-release-tools` to `main`. A lock or hook update runs the normal PR checks before merge. +## Refresh release tools + +Call `upgrade-release-tools.yaml` from a scheduled or manual workflow. Its read-only job refreshes the universal +`pylock.toml`. `release-creator locks` exports runtime dependencies, all extras, and all dependency groups with a +seven-day package cutoff. Reviewed exceptions live in `[tool.release-creator.locks.exclude-newer-package]`. Both lock +checks and weekly refresh install uv from the shared `release-resolver` group, so a setup-uv default change cannot alter +the generated lock. In github-workflows, the weekly updater also refreshes this direct pin and the lock together; +consumer locks use their pinned shared resolver. The job uploads a patch, which may change `pylock.toml`, an explicitly +configured secondary Nab lock, and `pyproject.toml` for expired reviewed cutoffs. The writer checks the patch before +applying it and verifies cutoff removals before minting the App token. It then opens or updates one dependency PR from +`automation/update-release-tools` to `main`. A lock update runs the normal PR checks before merge. The writer uses a repository-only release App with Contents and Pull requests write access. Install the App on this repository, set `RELEASE_APP_CLIENT_ID` as a repository variable, and store `RELEASE_APP_PRIVATE_KEY` in the @@ -141,9 +139,9 @@ For a second lock such as `pylock.build.toml`, set `additional-lock`, `additiona `additional-matrix-platforms`, and `additional-matrix-implementations`. `additional-lock-groups` selects dependency groups in that lock; with no groups, the updater selects build requirements. Set `additional-lock-selection: project` for a lock that selects the project's runtime dependencies instead. The lock must already exist, and the updater writes -both locks in the same dependency PR. The writer accepts only those locks, an opted-in `uv.lock`, `pyproject.toml`, and -the hook configuration. Pass the same `additional-lock` and matrix inputs to `validate-news` in the PR workflow. It -checks changes to either lock against the project, including a PR that changes only the secondary lock. +both locks in the same dependency PR. The writer accepts only those locks, an opted-in `uv.lock`, and `pyproject.toml`. +Pass the same `additional-lock` and matrix inputs to `validate-news` in the PR workflow. It checks changes to either +lock against the project, including a PR that changes only the secondary lock. `refresh-release-lock` is the read-only half of the updater. In `refresh` mode, it upgrades the full primary lock and writes `$RUNNER_TEMP/lock.patch` if a lock or reviewed cutoff changes. In `check` mode, it regenerates at the primary @@ -185,11 +183,6 @@ Include `.github/workflows/release.yaml` and `mise.lock` in the updater's artifa writer should run in a separate job; this Action does not request its token. The seven-day cutoff is fixed and cannot be lowered by an input. -The weekly updater freezes eligible hook revisions with `prek update --freeze --cooldown-days 7`. It then uses -`release-creator hooks` to pin Python hook dependencies under the same cutoff and writes a patch when -`.pre-commit-config.yaml` changes. The generation job has read-only repository permissions; the separate publish job -checks the patch paths and file modes before obtaining an App token. - Every shared `prek` command passes `--config .pre-commit-config.yaml`. Without it, prek prefers a root `prek.toml` and also runs nested configurations, which no guard checks. `prepare-towncrier` and `validate-release-patch` therefore reject a `prek.toml` anywhere, a `.pre-commit-config.yml`, and a `.pre-commit-config.yaml` below the root before they diff --git a/changelog.d/+frozen-hooks.breaking.md b/changelog.d/+frozen-hooks.breaking.md index 8cbbdfb..43a8222 100644 --- a/changelog.d/+frozen-hooks.breaking.md +++ b/changelog.d/+frozen-hooks.breaking.md @@ -1,4 +1,4 @@ Remove the `verify-frozen-hooks` Action with its frozen revision, tagged hook provenance, and hook dependency closure checks. The shared prepare workflow and `check-tagged-source` no longer run it; drop it from custom tag jobs. -`prepare-towncrier` and `validate-release-patch` now reject a `prek.toml` or a nested hook configuration before they -run `prek`. +`prepare-towncrier` and `validate-release-patch` now reject a `prek.toml` or a nested hook configuration before they run +`prek`. diff --git a/changelog.d/+weekly-hooks.breaking.md b/changelog.d/+weekly-hooks.breaking.md new file mode 100644 index 0000000..b631dba --- /dev/null +++ b/changelog.d/+weekly-hooks.breaking.md @@ -0,0 +1,3 @@ +Remove the hook refresh from `upgrade-release-tools.yaml`. The weekly updater no longer runs +`prek update --freeze --cooldown-days 7` or `release-creator hooks`, and its writer accepts only the lock patch; let +Dependabot's `pre-commit` ecosystem update hook revisions instead. diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py index 3fe25f4..d84462f 100644 --- a/tests/upgrade_release_tools/test_upgrade.py +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -96,26 +96,22 @@ def test_upgrade_emits_patch_for_allowed_change( @pytest.mark.parametrize( - ("path", "artifact", "additional_lock", "keep_uv_lock", "accepted"), + ("path", "additional_lock", "keep_uv_lock", "accepted"), [ - pytest.param("pylock.toml", "lock", "", False, True, id="lock"), - pytest.param("pyproject.toml", "lock", "", False, True, id="nab-policy"), - pytest.param("pylock.zipapp.toml", "lock", "pylock.zipapp.toml", False, True, id="secondary-lock"), - pytest.param(".pre-commit-config.yaml", "hooks", "", False, True, id="hooks"), - pytest.param(".pre-commit-config.yaml", "lock", "", False, False, id="hook-in-lock-artifact"), - pytest.param("pylock.toml", "hooks", "", False, False, id="lock-in-hook-artifact"), - pytest.param("pyproject.toml", "hooks", "", False, False, id="policy-in-hook-artifact"), - pytest.param("uv.lock", "lock", "", False, False, id="uv-lock-disabled"), - pytest.param("uv.lock", "lock", "", True, True, id="uv-lock-enabled"), - pytest.param("other.txt", "lock", "", False, False, id="unrelated-file"), + pytest.param("pylock.toml", "", False, True, id="lock"), + pytest.param("pyproject.toml", "", False, True, id="nab-policy"), + pytest.param("pylock.zipapp.toml", "pylock.zipapp.toml", False, True, id="secondary-lock"), + pytest.param(".pre-commit-config.yaml", "", False, False, id="hook-config"), + pytest.param("uv.lock", "", False, False, id="uv-lock-disabled"), + pytest.param("uv.lock", "", True, True, id="uv-lock-enabled"), + pytest.param("other.txt", "", False, False, id="unrelated-file"), ], ) -def test_upgrade_checks_each_artifact_path( +def test_upgrade_checks_lock_patch_path( lock_repository: Path, tmp_path: Path, publish_scripts: Path, path: str, - artifact: str, additional_lock: str, keep_uv_lock: bool, accepted: bool, @@ -126,20 +122,18 @@ def test_upgrade_checks_each_artifact_path( (lock_repository / path).write_text("after\n") patch = run(lock_repository, "git", "diff", "--binary", "--", path).stdout run(lock_repository, "git", "restore", path) - patch_dir = tmp_path / f"release-tools-{artifact}" + patch_dir = tmp_path / "release-tools-lock" patch_dir.mkdir() - (patch_dir / f"{artifact}.patch").write_text(patch) + (patch_dir / "lock.patch").write_text(patch) result = execute( lock_repository, publish_scripts / "publish-check-artifact-patch-paths.sh", tmp_path, ADDITIONAL_LOCK=additional_lock, KEEP_UV_LOCK=str(keep_uv_lock).lower(), - LOCK_CHANGED=str(artifact == "lock").lower(), - HOOKS_CHANGED=str(artifact == "hooks").lower(), ) assert (result.returncode == 0) == accepted - assert (f"unexpected {artifact} patch path: {path}" in result.stderr) == (not accepted) + assert (f"unexpected lock patch path: {path}" in result.stderr) == (not accepted) assert run(lock_repository, "git", "status", "--porcelain").stdout == "" @@ -158,85 +152,37 @@ def test_upgrade_rejects_unsafe_additional_lock( publish_scripts / "publish-check-artifact-patch-paths.sh", tmp_path, ADDITIONAL_LOCK=additional_lock, - LOCK_CHANGED="true", - HOOKS_CHANGED="false", ) assert result.returncode != 0 assert "invalid additional lock" in result.stderr -@pytest.mark.parametrize( - ("path", "unsafe_mode"), - [ - pytest.param("pylock.toml", "executable", id="executable-lock"), - pytest.param(".pre-commit-config.yaml", "symlink", id="symlink-hook-config"), - ], -) +@pytest.mark.parametrize("unsafe_mode", ["executable", "symlink"]) def test_upgrade_rejects_unsafe_file_mode( - lock_repository: Path, tmp_path: Path, publish_scripts: Path, path: str, unsafe_mode: str + lock_repository: Path, tmp_path: Path, publish_scripts: Path, unsafe_mode: str ) -> None: - target = lock_repository / path + target = lock_repository / "pylock.toml" target.write_text("before\n") - run(lock_repository, "git", "add", path) + run(lock_repository, "git", "add", "pylock.toml") run(lock_repository, "git", "commit", "-m", "Add lock") if unsafe_mode == "executable": target.chmod(0o755) else: target.unlink() target.symlink_to("other.txt") - patch = run(lock_repository, "git", "diff", "--binary", "--", path).stdout - run(lock_repository, "git", "restore", path) - patch_dir = tmp_path / ("release-tools-lock" if path == "pylock.toml" else "release-tools-hooks") + patch = run(lock_repository, "git", "diff", "--binary", "--", "pylock.toml").stdout + run(lock_repository, "git", "restore", "pylock.toml") + patch_dir = tmp_path / "release-tools-lock" patch_dir.mkdir() - (patch_dir / ("lock.patch" if path == "pylock.toml" else "hooks.patch")).write_text(patch) - applied = execute( - lock_repository, - publish_scripts / ("publish-apply-lock-patch.sh" if path == "pylock.toml" else "publish-apply-hook-patch.sh"), - tmp_path, - ) + (patch_dir / "lock.patch").write_text(patch) + applied = execute(lock_repository, publish_scripts / "publish-apply-lock-patch.sh", tmp_path) assert applied.returncode == 0, applied.stderr result = execute(lock_repository, publish_scripts / "publish-check-dependency-patch-file-mode.sh", tmp_path) assert result.returncode != 0 assert "unexpected dependency file mode" in result.stderr -def test_upgrade_combines_lock_and_hook_patches(lock_repository: Path, tmp_path: Path, publish_scripts: Path) -> None: - lock = lock_repository / "pylock.toml" - config = lock_repository / ".pre-commit-config.yaml" - lock.write_text('lock-version = "1.0"\n') - config.write_text("repos: []\n") - run(lock_repository, "git", "add", ".") - run(lock_repository, "git", "commit", "-m", "Add dependency files") - lock.write_text('lock-version = "1.1"\n') - lock_patch = run(lock_repository, "git", "diff", "--binary", "--", "pylock.toml").stdout - config.write_text("repos: [local]\n") - hooks_patch = run(lock_repository, "git", "diff", "--binary", "--", ".pre-commit-config.yaml").stdout - run(lock_repository, "git", "restore", ".") - lock_artifact = tmp_path / "release-tools-lock" - lock_artifact.mkdir() - (lock_artifact / "lock.patch").write_text(lock_patch) - hooks_artifact = tmp_path / "release-tools-hooks" - hooks_artifact.mkdir() - (hooks_artifact / "hooks.patch").write_text(hooks_patch) - - checked = execute( - lock_repository, - publish_scripts / "publish-check-artifact-patch-paths.sh", - tmp_path, - LOCK_CHANGED="true", - HOOKS_CHANGED="true", - ) - applied_lock = execute(lock_repository, publish_scripts / "publish-apply-lock-patch.sh", tmp_path) - applied_hooks = execute(lock_repository, publish_scripts / "publish-apply-hook-patch.sh", tmp_path) - - assert (applied_lock.returncode, applied_hooks.returncode, checked.returncode) == (0, 0, 0) - assert run(lock_repository, "git", "diff", "--cached", "--name-only").stdout.splitlines() == [ - ".pre-commit-config.yaml", - "pylock.toml", - ] - - def test_upgrade_preserves_requested_pull_request_body( lock_repository: Path, tmp_path: Path, publish_scripts: Path ) -> None: From fba1c9f12f026e0c4a0333b1faaaa58973cd4a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 12:42:10 -0700 Subject: [PATCH 04/35] =?UTF-8?q?=F0=9F=94=A5=20feat(mise)!:=20drop=20the?= =?UTF-8?q?=20refresh-mise-pin=20Action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove refresh-mise-pin and refresh_mise_pin.py with their tests. Callers rely on mise-action's default release age. --- README.md | 33 -- changelog.d/+mise-pin.breaking.md | 2 + refresh-mise-pin/action.yml | 88 ---- tests/refresh_mise_pin/test_refresh.py | 542 ------------------------- tools/refresh_mise_pin.py | 233 ----------- 5 files changed, 2 insertions(+), 896 deletions(-) create mode 100644 changelog.d/+mise-pin.breaking.md delete mode 100644 refresh-mise-pin/action.yml delete mode 100644 tests/refresh_mise_pin/test_refresh.py delete mode 100644 tools/refresh_mise_pin.py diff --git a/README.md b/README.md index df9319b..2deff0a 100644 --- a/README.md +++ b/README.md @@ -150,39 +150,6 @@ changes release inputs, including a workspace member's `pyproject.toml` or a sec formatting job runs `prek`; the writer checks patch paths and modes. Its `changed` output is `true` only when the refresh produces a patch. Neither mode pushes a branch or requests a write token. -## Refresh a pinned mise binary - -`refresh-mise-pin` updates one named `jdx/mise-action` step in a release workflow. It selects the newest stable mise -GitHub Release whose publication and Linux executable and archive uploads are at least seven days old. Those assets must -also be unchanged for seven days. The Action installs that version through the pinned mise Action and compares the -executable with the release asset digest. Before invoking mise, the mise Action verifies its downloaded archive against -the minisign-signed checksum file. The refresh Action fails if the named step is missing or duplicated, its version or -digest is not literal, or the installed binary differs from the selected release. It changes only that step's `version` -and `sha256` values. The verified mise executable is on `PATH` and at `$RUNNER_TEMP/refresh-mise-pin/bin/mise` for the -next step; `export_path: false` disables mise-generated paths, not the binary's path. - -Call it in a read-only Ubuntu job after checkout and setup-uv with Python 3.15. The Action installs its YAML parser from -the shared hash-checked lock. `workflow` is the path under `.github/workflows`; `step-name` is the exact name of the -mise step in that file. `github-token` needs public release read access. `changed` is `true` only when the two pins -change; `version` omits the leading `v`, and `sha256` is the Linux x64 executable digest. The Action does not update -`mise.lock` or open a PR. - -```yaml - - uses: tox-dev/github-workflows/refresh-mise-pin@SHA - id: mise - with: - workflow: .github/workflows/release.yaml - step-name: 🔧 Set up mise (JDKs) - github-token: ${{ github.token }} - - name: Refresh locked JDKs - run: >- - $RUNNER_TEMP/refresh-mise-pin/bin/mise lock --bump --minimum-release-age 7d -``` - -Include `.github/workflows/release.yaml` and `mise.lock` in the updater's artifact and App writer path allowlists. The -writer should run in a separate job; this Action does not request its token. The seven-day cutoff is fixed and cannot be -lowered by an input. - Every shared `prek` command passes `--config .pre-commit-config.yaml`. Without it, prek prefers a root `prek.toml` and also runs nested configurations, which no guard checks. `prepare-towncrier` and `validate-release-patch` therefore reject a `prek.toml` anywhere, a `.pre-commit-config.yml`, and a `.pre-commit-config.yaml` below the root before they diff --git a/changelog.d/+mise-pin.breaking.md b/changelog.d/+mise-pin.breaking.md new file mode 100644 index 0000000..18eb42f --- /dev/null +++ b/changelog.d/+mise-pin.breaking.md @@ -0,0 +1,2 @@ +Remove the `refresh-mise-pin` Action. Callers that pinned a mise binary with it keep `jdx/mise-action`'s default +release age instead. diff --git a/refresh-mise-pin/action.yml b/refresh-mise-pin/action.yml deleted file mode 100644 index f71b4dc..0000000 --- a/refresh-mise-pin/action.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Refresh mise release pin -description: Update a release workflow to a seven-day-old, signature-verified mise binary -inputs: - workflow: - description: Repository-relative workflow containing the pinned mise step - required: true - step-name: - description: Exact name of the mise setup step in that workflow - required: true - github-token: - description: Token with read access to public GitHub releases - required: true -outputs: - changed: - description: Whether the workflow's mise version or digest changed - value: ${{ steps.update.outputs.changed }} - version: - description: Selected mise version without the v prefix - value: ${{ steps.update.outputs.version }} - sha256: - description: SHA256 of the verified Linux x64 mise executable - value: ${{ steps.update.outputs.sha256 }} -runs: - using: composite - steps: - - name: List upstream releases - shell: bash - env: - GH_TOKEN: ${{ inputs.github-token }} - run: | - gh api --paginate \ - --jq '.[] | {tag_name,published_at,prerelease,draft,assets:[.assets[] | - select(.name | endswith("-linux-x64") or endswith("-linux-x64.tar.zst") or - endswith("-linux-x64.tar.gz")) | - {name,digest,created_at,updated_at}]} | @json' \ - 'repos/jdx/mise/releases?per_page=100' >"$RUNNER_TEMP/mise-releases.jsonl" - - name: Install locked YAML editor - uses: $/install-locked-tools - with: - groups: release-mise-editor - venv: ${{ runner.temp }}/mise-editor - lock-source: shared - - name: Select seven-day-old release - id: select - shell: bash - run: | - uv run --no-project --python "$RUNNER_TEMP/mise-editor" python \ - "$GITHUB_ACTION_PATH/../tools/refresh_mise_pin.py" select \ - "$RUNNER_TEMP/mise-releases.jsonl" "$GITHUB_OUTPUT" - - name: Download the selected mise executable - shell: bash - env: - GH_TOKEN: ${{ inputs.github-token }} - VERSION: ${{ steps.select.outputs.version }} - EXPECTED_SHA256: ${{ steps.select.outputs.sha256 }} - run: | - set -euo pipefail - asset="mise-v$VERSION-linux-x64" - mkdir -p "$RUNNER_TEMP/mise-download" "$RUNNER_TEMP/refresh-mise-pin/bin" "$RUNNER_TEMP/refresh-mise-pin-cwd" - gh release download "v$VERSION" --repo jdx/mise --pattern "$asset" --dir "$RUNNER_TEMP/mise-download" - printf '%s %s\n' "$EXPECTED_SHA256" "$RUNNER_TEMP/mise-download/$asset" | sha256sum --check --strict - install -m 0755 "$RUNNER_TEMP/mise-download/$asset" "$RUNNER_TEMP/refresh-mise-pin/bin/mise" - printf '%s' "$VERSION" >"$RUNNER_TEMP/refresh-mise-pin/bin/mise-version" - # mise-action checks an existing binary against the signed checksums before it runs it. - - name: Install signature-verified mise - uses: jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8 # v5.1.2 - with: - version: ${{ steps.select.outputs.version }} - mise_dir: ${{ runner.temp }}/refresh-mise-pin - working_directory: ${{ runner.temp }}/refresh-mise-pin-cwd - cache: "false" - install: "false" - env: "false" - export_path: "false" - add_shims_to_path: "false" - - name: Update the workflow pin - id: update - shell: bash - env: - WORKFLOW: ${{ inputs.workflow }} - STEP_NAME: ${{ inputs.step-name }} - VERSION: ${{ steps.select.outputs.version }} - EXPECTED_SHA256: ${{ steps.select.outputs.sha256 }} - run: | - uv run --no-project --python "$RUNNER_TEMP/mise-editor" python \ - "$GITHUB_ACTION_PATH/../tools/refresh_mise_pin.py" update \ - "$WORKFLOW" "$STEP_NAME" "$VERSION" "$EXPECTED_SHA256" \ - "$RUNNER_TEMP/refresh-mise-pin/bin/mise" "$GITHUB_OUTPUT" diff --git a/tests/refresh_mise_pin/test_refresh.py b/tests/refresh_mise_pin/test_refresh.py deleted file mode 100644 index 2dfd9a4..0000000 --- a/tests/refresh_mise_pin/test_refresh.py +++ /dev/null @@ -1,542 +0,0 @@ -from __future__ import annotations - -import json -import os -import subprocess -import sys -from datetime import UTC, datetime, timedelta -from hashlib import sha256 -from pathlib import Path -from typing import cast - -import pytest -import yaml - -from tools.refresh_mise_pin import main - - -def _release( - version: str, - *, - days: int, - prerelease: bool = False, - draft: bool = False, - asset: bool = True, - digest: str | None = "sha256:" + "a" * 64, - asset_days: int | None = None, - archive: bool = True, - recent_archive: str = "", -) -> dict[str, object]: - asset_date = (datetime.now(UTC) - timedelta(days=days if asset_days is None else asset_days)).isoformat() - archive_date = (datetime.now(UTC) - timedelta(days=days)).isoformat() - assets = ( - [{"name": f"mise-v{version}-linux-x64", "digest": digest, "created_at": asset_date, "updated_at": asset_date}] - if asset - else [] - ) - if archive: - assets.extend( - { - "name": f"mise-v{version}-linux-x64{suffix}", - "digest": "sha256:" + "b" * 64, - "created_at": archive_date, - "updated_at": datetime.now(UTC).isoformat() if suffix == recent_archive else archive_date, - } - for suffix in (".tar.zst", ".tar.gz") - ) - return { - "tag_name": f"v{version}", - "published_at": (datetime.now(UTC) - timedelta(days=days)).isoformat(), - "prerelease": prerelease, - "draft": draft, - "assets": assets, - } - - -def _step(version: str, digest: str) -> str: - return ( - " - name: 🔧 Set up mise (JDKs)\n" - " uses: jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8 # v5.1.2\n" - " with:\n" - f" version: {version}\n" - f" sha256: {digest}\n" - ) - - -@pytest.mark.parametrize( - ("workflow_text", "old_version"), - [ - pytest.param( - "jobs:\n build:\n steps:\n - name: 🔧 Set up mise (JDKs)\n" - " uses: jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8 # v5.1.2\n" - ' with:\n version: "2026.10.12"\n sha256: ' - + "0" * 64 - + "\n - name: Build\n run: echo unchanged\n", - 'version: "2026.10.13"', - id="jetbrains-fish", - ), - pytest.param( - "jobs:\n build:\n steps:\n - name: 🔧 Set up mise (JDKs)\n" - " uses: jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8 # v5.1.2\n" - " with:\n version: 2026.10.12\n sha256: " - + "0" * 64 - + "\n - name: Build\n run: echo unchanged\n", - "version: 2026.10.13", - id="pyvenvmanage", - ), - ], -) -def test_update_real_workflow_styles( - tmp_path: Path, workflow_text: str, old_version: str, mise_binary: tuple[Path, str] -) -> None: - binary, digest = mise_binary - workflow = _workflow(tmp_path, workflow_text) - result = _run( - tmp_path, - "update", - str(workflow.relative_to(tmp_path)), - "🔧 Set up mise (JDKs)", - "2026.10.13", - digest, - str(binary), - ) - assert result.returncode == 0, result.stderr - assert old_version in workflow.read_text() - assert f"sha256: {digest}" in workflow.read_text() - assert "run: echo unchanged" in workflow.read_text() - assert (tmp_path / "output").read_text() == f"changed=true\nversion=2026.10.13\nsha256={digest}\n" - - -def test_update_does_not_rewrite_unchanged_pin(tmp_path: Path, mise_binary: tuple[Path, str]) -> None: - binary, digest = mise_binary - workflow = _workflow(tmp_path, _step("2026.10.13", digest)) - before = workflow.stat().st_mtime_ns - result = _run( - tmp_path, - "update", - str(workflow.relative_to(tmp_path)), - "🔧 Set up mise (JDKs)", - "2026.10.13", - digest, - str(binary), - ) - assert result.returncode == 0, result.stderr - assert workflow.stat().st_mtime_ns == before - assert (tmp_path / "output").read_text().startswith("changed=false\n") - - -def test_update_rejects_downgrade(tmp_path: Path, mise_binary: tuple[Path, str]) -> None: - binary, digest = mise_binary - workflow = _workflow(tmp_path, _step("2026.10.14", "0" * 64)) - result = _run( - tmp_path, - "update", - ".github/workflows/release.yaml", - "🔧 Set up mise (JDKs)", - "2026.10.13", - digest, - str(binary), - ) - assert result.returncode != 0 - assert "older than the current pin" in result.stderr - assert "version: 2026.10.14" in workflow.read_text() - - -def test_update_preserves_following_step_fields(tmp_path: Path, mise_binary: tuple[Path, str]) -> None: - binary, digest = mise_binary - workflow = _workflow(tmp_path, _step("2026.10.12", "0" * 64) + " env:\n OTHER: yes\n") - result = _run( - tmp_path, - "update", - ".github/workflows/release.yaml", - "🔧 Set up mise (JDKs)", - "2026.10.13", - digest, - str(binary), - ) - assert result.returncode == 0, result.stderr - assert " env:\n OTHER: yes\n" in workflow.read_text() - - -def test_update_ignores_step_text_inside_run_block(tmp_path: Path, mise_binary: tuple[Path, str]) -> None: - binary, digest = mise_binary - fake = _step("2026.10.12", "0" * 64) - content = ( - "jobs:\n build:\n steps:\n - name: Write fixture\n run: |\n" - + "".join(f" {line}" for line in fake.splitlines(keepends=True)) - + _step("2026.10.12", "0" * 64) - ) - workflow = _workflow(tmp_path, content) - result = _run( - tmp_path, "update", ".github/workflows/release.yaml", "🔧 Set up mise (JDKs)", "2026.10.13", digest, str(binary) - ) - assert result.returncode == 0, result.stderr - assert workflow.read_text().count("version: 2026.10.12") == 1 - assert workflow.read_text().count("version: 2026.10.13") == 1 - - -def test_update_rejects_aliased_inputs_from_other_step(tmp_path: Path, mise_binary: tuple[Path, str]) -> None: - binary, digest = mise_binary - content = ( - "jobs:\n build:\n steps:\n" - " - name: Other\n uses: jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8\n" - f" with: &mise\n version: 2026.10.12\n sha256: {'0' * 64}\n" - " - name: 🔧 Set up mise (JDKs)\n" - " uses: jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8\n" - " with: *mise\n" - ) - workflow = _workflow(tmp_path, content) - result = _run( - tmp_path, "update", ".github/workflows/release.yaml", "🔧 Set up mise (JDKs)", "2026.10.13", digest, str(binary) - ) - assert result.returncode != 0 - assert "YAML aliases outside" in result.stderr - assert workflow.read_text() == content - - -def test_update_rejects_inputs_aliased_by_later_step(tmp_path: Path, mise_binary: tuple[Path, str]) -> None: - binary, digest = mise_binary - content = ( - "jobs:\n build:\n steps:\n" - " - name: 🔧 Set up mise (JDKs)\n" - " uses: jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8\n" - f" with: &mise\n version: 2026.10.12\n sha256: {'0' * 64}\n" - " - name: Other\n uses: jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8\n" - " with: *mise\n" - ) - workflow = _workflow(tmp_path, content) - result = _run( - tmp_path, "update", ".github/workflows/release.yaml", "🔧 Set up mise (JDKs)", "2026.10.13", digest, str(binary) - ) - assert result.returncode != 0 - assert "aliased elsewhere" in result.stderr - assert workflow.read_text() == content - - -def test_update_rejects_shadowed_job(tmp_path: Path, mise_binary: tuple[Path, str]) -> None: - binary, digest = mise_binary - content = "jobs:\n build:\n steps:\n" + _step("2026.10.12", "0" * 64) + " build:\n steps: []\n" - workflow = _workflow(tmp_path, content) - result = _run( - tmp_path, "update", ".github/workflows/release.yaml", "🔧 Set up mise (JDKs)", "2026.10.13", digest, str(binary) - ) - assert result.returncode != 0 - assert "duplicate YAML job name" in result.stderr - assert workflow.read_text() == content - - -@pytest.mark.parametrize("content", ["[]\n", "jobs: []\n"], ids=["root", "jobs"]) -def test_update_rejects_missing_jobs_mapping(tmp_path: Path, mise_binary: tuple[Path, str], content: str) -> None: - binary, digest = mise_binary - workflow = _workflow(tmp_path, _step("2026.10.12", "0" * 64)) - workflow.write_text(content) - result = _run( - tmp_path, "update", ".github/workflows/release.yaml", "🔧 Set up mise (JDKs)", "2026.10.13", digest, str(binary) - ) - assert result.returncode != 0 - assert "workflow needs a jobs mapping" in result.stderr - - -def test_update_handles_unrelated_recursive_alias(tmp_path: Path, mise_binary: tuple[Path, str]) -> None: - binary, digest = mise_binary - content = "jobs:\n build:\n steps:\n - name: Other\n env: &loop [*loop]\n" + _step( - "2026.10.12", "0" * 64 - ) - workflow = _workflow(tmp_path, content) - result = _run( - tmp_path, "update", ".github/workflows/release.yaml", "🔧 Set up mise (JDKs)", "2026.10.13", digest, str(binary) - ) - assert result.returncode == 0, result.stderr - assert "version: 2026.10.13" in workflow.read_text() - - -@pytest.mark.parametrize( - ("content", "message"), - [ - pytest.param("jobs: {}\n", "found 0", id="missing-step"), - pytest.param( - "jobs:\n build:\n steps:\n" + _step("2026.10.12", "0" * 64) * 2, "found 2", id="duplicate-step" - ), - pytest.param( - _step("2026.10.12", "0" * 64).replace("jdx/mise-action@08", "jdx/mise-action@v5."), - "SHA-pinned", - id="mutable-action", - ), - pytest.param(_step("2026.10.12", "0" * 64).split(" with:\n")[0], "with mapping", id="missing-with"), - pytest.param( - _step("2026.10.12", "0" * 64).replace(" sha256: " + "0" * 64, ""), - "literal version and sha256", - id="missing-digest", - ), - pytest.param( - _step("2026.10.12", "0" * 64).replace(" version: 2026.10.12", " version: latest"), - "literal version and sha256", - id="floating-version", - ), - pytest.param( - _step("2026.10.12", "0" * 64).replace( - " sha256: " + "0" * 64, " sha256: " + "0" * 64 + "\n sha256: " + "1" * 64 - ), - "duplicate YAML key: sha256", - id="duplicate-digest", - ), - ], -) -def test_update_rejects_ambiguous_workflow( - tmp_path: Path, mise_binary: tuple[Path, str], content: str, message: str -) -> None: - binary, digest = mise_binary - workflow = _workflow(tmp_path, content) - original = workflow.read_text() - result = _run( - tmp_path, - "update", - str(workflow.relative_to(tmp_path)), - "🔧 Set up mise (JDKs)", - "2026.10.13", - digest, - str(binary), - ) - assert result.returncode != 0 - assert message in result.stderr - assert workflow.read_text() == original - - -@pytest.mark.parametrize( - ("workflow_name", "version", "digest", "binary_version", "message"), - [ - pytest.param("README.md", "2026.10.13", "a" * 64, "2026.10.13", "workflow must", id="outside-workflows"), - pytest.param( - ".github/workflows/release.yaml", - "latest", - "a" * 64, - "2026.10.13", - "invalid mise version", - id="invalid-version", - ), - pytest.param( - ".github/workflows/release.yaml", - "2026.10.13", - "bad", - "2026.10.13", - "invalid mise digest", - id="invalid-digest", - ), - pytest.param( - ".github/workflows/release.yaml", - "2026.10.13", - "a" * 64, - "2026.10.12", - "did not install", - id="wrong-binary-version", - ), - pytest.param( - ".github/workflows/release.yaml", "2026.10.13", "0" * 64, "2026.10.13", "differs", id="wrong-binary-digest" - ), - ], -) -def test_update_rejects_unverified_inputs( - tmp_path: Path, workflow_name: str, version: str, digest: str, binary_version: str, message: str -) -> None: - workflow = tmp_path / workflow_name - workflow.parent.mkdir(parents=True, exist_ok=True) - workflow.write_text("jobs:\n build:\n steps:\n" + _step("2026.10.12", "0" * 64)) - binary = tmp_path / "bin" / "mise" - binary.parent.mkdir() - binary.write_bytes(b"verified mise") - binary.with_name("mise-version").write_text(binary_version) - result = _run(tmp_path, "update", workflow_name, "🔧 Set up mise (JDKs)", version, digest, str(binary)) - assert result.returncode != 0 - assert message in result.stderr - - -def test_update_rejects_symlinked_workflow(tmp_path: Path, mise_binary: tuple[Path, str]) -> None: - binary, digest = mise_binary - target = _workflow(tmp_path, _step("2026.10.12", "0" * 64)) - target.rename(target.with_name("actual.yaml")) - target.symlink_to("actual.yaml") - result = _run( - tmp_path, "update", ".github/workflows/release.yaml", "🔧 Set up mise (JDKs)", "2026.10.13", digest, str(binary) - ) - assert result.returncode != 0 - assert "regular YAML" in result.stderr - - -def test_select_newest_stable_aged_release(tmp_path: Path) -> None: - releases = [ - _release("2026.10.15", days=1), - _release("2026.10.14", days=8, prerelease=True), - _release("2026.10.13", days=8), - _release("2026.10.12", days=9), - ] - (tmp_path / "releases.jsonl").write_text("\n".join(map(json.dumps, releases))) - result = _run(tmp_path, "select", "releases.jsonl") - assert result.returncode == 0, result.stderr - assert (tmp_path / "output").read_text() == f"version=2026.10.13\nsha256={'a' * 64}\n" - - -def test_select_rejects_recent_asset_on_aged_release(tmp_path: Path) -> None: - (tmp_path / "releases.jsonl").write_text(json.dumps(_release("2026.10.13", days=8, asset_days=1))) - result = _run(tmp_path, "select", "releases.jsonl") - assert result.returncode != 0 - assert "no mise executable and archives were uploaded and unchanged" in result.stderr - - -def test_select_uses_older_release_when_newer_asset_is_recent(tmp_path: Path) -> None: - releases = [_release("2026.10.13", days=8, asset_days=1), _release("2026.10.12", days=9)] - (tmp_path / "releases.jsonl").write_text("\n".join(map(json.dumps, releases))) - result = _run(tmp_path, "select", "releases.jsonl") - assert result.returncode == 0, result.stderr - assert (tmp_path / "output").read_text() == f"version=2026.10.12\nsha256={'a' * 64}\n" - - -@pytest.mark.parametrize("suffix", [pytest.param(".tar.zst", id="zstd"), pytest.param(".tar.gz", id="gzip")]) -def test_select_rejects_recent_archive_on_aged_release(tmp_path: Path, suffix: str) -> None: - (tmp_path / "releases.jsonl").write_text(json.dumps(_release("2026.10.13", days=8, recent_archive=suffix))) - result = _run(tmp_path, "select", "releases.jsonl") - assert result.returncode != 0 - assert "no mise executable and archives were uploaded and unchanged" in result.stderr - - -def test_select_uses_older_release_when_newer_archive_is_recent(tmp_path: Path) -> None: - releases = [_release("2026.10.13", days=8, recent_archive=".tar.zst"), _release("2026.10.12", days=9)] - (tmp_path / "releases.jsonl").write_text("\n".join(map(json.dumps, releases))) - result = _run(tmp_path, "select", "releases.jsonl") - assert result.returncode == 0, result.stderr - assert (tmp_path / "output").read_text() == f"version=2026.10.12\nsha256={'a' * 64}\n" - - -def test_main_selects_release(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - (tmp_path / "releases.jsonl").write_text(json.dumps(_release("2026.10.13", days=8))) - monkeypatch.setattr( - sys, "argv", ["refresh_mise_pin.py", "select", str(tmp_path / "releases.jsonl"), str(tmp_path / "output")] - ) - main() - assert (tmp_path / "output").read_text() == f"version=2026.10.13\nsha256={'a' * 64}\n" - - -@pytest.mark.parametrize( - ("published", "accepted"), - [pytest.param(b"verified mise", True, id="selected-digest"), pytest.param(b"swapped", False, id="other-bytes")], -) -def test_action_checks_executable_digest_before_mise_runs(tmp_path: Path, published: bytes, accepted: bool) -> None: - steps = yaml.safe_load((Path(__file__).resolve().parents[2] / "refresh-mise-pin" / "action.yml").read_text())[ - "runs" - ]["steps"] - download = next(step for step in steps if step.get("name") == "Download the selected mise executable") - installer = next(step for step in steps if step.get("uses", "").startswith("jdx/mise-action@")) - assert steps.index(download) < steps.index(installer) - fake_bin = tmp_path / "fake-bin" - fake_bin.mkdir() - (tmp_path / "published").write_bytes(published) - gh = fake_bin / "gh" - gh.write_text(f'#!/bin/sh\ncp {tmp_path / "published"} "$9/mise-v2026.10.13-linux-x64"\n') - gh.chmod(0o755) - result = subprocess.run( - ["bash", "-e", "-o", "pipefail", "-c", download["run"]], - env=os.environ - | { - "PATH": f"{fake_bin}:{os.environ['PATH']}", - "RUNNER_TEMP": str(tmp_path), - "VERSION": "2026.10.13", - "EXPECTED_SHA256": sha256(b"verified mise").hexdigest(), - }, - text=True, - capture_output=True, - check=False, - ) - binary = tmp_path / "refresh-mise-pin" / "bin" / "mise" - assert (result.returncode == 0) is accepted, result.stderr - assert binary.exists() is accepted - if accepted: - assert os.access(binary, os.X_OK) - assert binary.with_name("mise-version").read_text() == "2026.10.13" - - -def test_update_keeps_crlf_line_endings(tmp_path: Path, mise_binary: tuple[Path, str]) -> None: - binary, digest = mise_binary - workflow = tmp_path / ".github" / "workflows" / "release.yaml" - workflow.parent.mkdir(parents=True) - workflow.write_bytes( - ("jobs:\n build:\n steps:\n" + _step("2026.10.12", "0" * 64)).replace("\n", "\r\n").encode() - ) - result = _run( - tmp_path, - "update", - ".github/workflows/release.yaml", - "🔧 Set up mise (JDKs)", - "2026.10.13", - digest, - str(binary), - ) - assert result.returncode == 0, result.stderr - assert workflow.read_bytes() == ( - ("jobs:\n build:\n steps:\n" + _step("2026.10.13", digest)).replace("\n", "\r\n").encode() - ) - - -def test_action_keeps_linux_executable_and_archives_in_release_listing() -> None: - action = yaml.safe_load((Path(__file__).resolve().parents[2] / "refresh-mise-pin" / "action.yml").read_text()) - query = action["runs"]["steps"][0]["run"].split("--jq '", 1)[1].split("'", 1)[0] - release = _release("2026.10.13", days=8) - cast(list[dict[str, object]], release["assets"]).append( - {"name": "mise-v2026.10.13-linux-x64-musl", "digest": "sha256:" + "c" * 64} - ) - result = subprocess.run( - ["jq", "-r", query], input=json.dumps([release]), text=True, capture_output=True, check=True - ) - assert {asset["name"] for asset in json.loads(result.stdout)["assets"]} == { - "mise-v2026.10.13-linux-x64", - "mise-v2026.10.13-linux-x64.tar.zst", - "mise-v2026.10.13-linux-x64.tar.gz", - } - - -@pytest.mark.parametrize( - ("releases", "message"), - [ - pytest.param([_release("2026.10.13", days=1)], "no stable mise", id="too-young"), - pytest.param([_release("2026.10.13", days=8, draft=True)], "no stable mise", id="draft"), - pytest.param([_release("2026.10.13", days=8, asset=False)], "no unique Linux", id="missing-asset"), - pytest.param([_release("2026.10.13", days=8, archive=False)], "no unique Linux", id="missing-archive"), - pytest.param([_release("2026.10.13", days=8, digest="sha256:bad")], "no unique Linux", id="bad-asset-digest"), - pytest.param([_release("2026.10.13", days=8, digest=None)], "no unique Linux", id="null-asset-digest"), - ], -) -def test_select_rejects_invalid_release(tmp_path: Path, releases: list[dict[str, object]], message: str) -> None: - (tmp_path / "releases.jsonl").write_text("\n".join(map(json.dumps, releases))) - result = _run(tmp_path, "select", "releases.jsonl") - assert result.returncode != 0 - assert message in result.stderr - - -@pytest.fixture -def mise_binary(tmp_path: Path) -> tuple[Path, str]: - binary = tmp_path / "bin" / "mise" - binary.parent.mkdir() - binary.write_bytes(b"verified mise") - binary.with_name("mise-version").write_text("2026.10.13") - return binary, sha256(binary.read_bytes()).hexdigest() - - -def _workflow(tmp_path: Path, content: str) -> Path: - workflow = tmp_path / ".github" / "workflows" / "release.yaml" - workflow.parent.mkdir(parents=True) - workflow.write_text(content if content.startswith("jobs:") else "jobs:\n build:\n steps:\n" + content) - return workflow - - -def _run(tmp_path: Path, command: str, *arguments: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [ - sys.executable, - str(Path(__file__).resolve().parents[2] / "tools" / "refresh_mise_pin.py"), - command, - *arguments, - str(tmp_path / "output"), - ], - cwd=tmp_path, - text=True, - capture_output=True, - check=False, - ) diff --git a/tools/refresh_mise_pin.py b/tools/refresh_mise_pin.py deleted file mode 100644 index 156afb1..0000000 --- a/tools/refresh_mise_pin.py +++ /dev/null @@ -1,233 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import os -import re -import stat -import tempfile -from datetime import UTC, datetime, timedelta -from hashlib import file_digest -from pathlib import Path -from typing import Final, TypedDict, cast - -import yaml -from yaml.error import Mark -from yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode - - -class _Asset(TypedDict): - name: str - digest: str | None - created_at: str - updated_at: str - - -class _Release(TypedDict): - tag_name: str - published_at: str - draft: bool - prerelease: bool - assets: list[_Asset] - - -_VERSION: Final[re.Pattern[str]] = re.compile( - r""" - (?P\d{4})\. - (?P\d{1,2})\. - (?P\d+) - """, - re.VERBOSE, -) - - -def main() -> None: - parser = argparse.ArgumentParser(description="Refresh a signed mise release pin") - commands = parser.add_subparsers(dest="command", required=True) - select = commands.add_parser("select") - select.add_argument("releases", type=Path) - select.add_argument("output", type=Path) - update = commands.add_parser("update") - update.add_argument("workflow", type=Path) - update.add_argument("step_name") - update.add_argument("version") - update.add_argument("expected_sha256") - update.add_argument("binary", type=Path) - update.add_argument("output", type=Path) - args = parser.parse_args() - try: - if args.command == "select": - version, digest = _select_release(args.releases) - _write_outputs(args.output, version=version, sha256=digest) - else: - changed = _update_pin(args.workflow, args.step_name, args.version, args.expected_sha256, args.binary) - _write_outputs(args.output, changed=str(changed).lower(), version=args.version, sha256=args.expected_sha256) - except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError, yaml.YAMLError) as error: - parser.error(str(error)) - - -def _select_release(path: Path) -> tuple[str, str]: - cutoff = datetime.now(UTC) - timedelta(days=7) - releases = (cast(_Release, json.loads(line)) for line in path.read_text().splitlines()) - eligible: list[tuple[tuple[int, ...], _Release]] = [] - for release in releases: - if ( - release["draft"] - or release["prerelease"] - or not release["tag_name"].startswith("v") - or not (version := _VERSION.fullmatch(release["tag_name"][1:])) - ): - continue - published = datetime.fromisoformat(release["published_at"]) - if published <= cutoff: - eligible.append((tuple(map(int, version.groups())), release)) - if not eligible: - raise ValueError("no stable mise release was published at least seven days ago") - for _, release in sorted(eligible, key=lambda item: item[0], reverse=True): - version = release["tag_name"][1:] - assets: list[_Asset] = [] - for suffix in ("", ".tar.zst", ".tar.gz"): - name = f"mise-v{version}-linux-x64{suffix}" - matches = [asset for asset in release["assets"] if asset["name"] == name] - if ( - len(matches) != 1 - or not isinstance(matches[0]["digest"], str) - or not matches[0]["digest"].startswith("sha256:") - or not _hex(matches[0]["digest"][7:], 64) - ): - raise ValueError(f"mise {version} has no unique Linux x64 asset digest for {name}") - assets.append(matches[0]) - if all( - datetime.fromisoformat(asset[key]) <= cutoff for asset in assets for key in ("created_at", "updated_at") - ): - return version, assets[0]["digest"].removeprefix("sha256:") - raise ValueError("no mise executable and archives were uploaded and unchanged for at least seven days") - - -def _update_pin(workflow: Path, step_name: str, version: str, expected_sha256: str, binary: Path) -> bool: - if workflow.is_absolute() or workflow.parts[:2] != (".github", "workflows") or len(workflow.parts) != 3: - raise ValueError("workflow must be a file in .github/workflows") - if workflow.suffix not in {".yaml", ".yml"} or workflow.is_symlink(): - raise ValueError("workflow must be a regular YAML file") - if not _VERSION.fullmatch(version): - raise ValueError("invalid mise version") - if not _hex(expected_sha256, 64): - raise ValueError("invalid mise digest") - if binary.is_symlink() or not binary.is_file() or binary.with_name("mise-version").read_text().strip() != version: - raise ValueError("mise-action did not install the selected version") - with binary.open("rb") as stream: - if file_digest(stream, "sha256").hexdigest() != expected_sha256: - raise ValueError("verified mise binary differs from the GitHub release asset digest") - with workflow.open(newline="") as stream: - source = stream.read() - root = yaml.compose(source) - if not isinstance(root, MappingNode): - raise TypeError("workflow needs a jobs mapping") - jobs = _value(root, "jobs") - if not isinstance(jobs, MappingNode): - raise TypeError("workflow needs a jobs mapping") - names = [name.value for name, _ in jobs.value if isinstance(name, ScalarNode)] - if len(names) != len(set(names)): - raise ValueError("duplicate YAML job name") - matches = [ - step - for _, job in jobs.value - if isinstance(job, MappingNode) and isinstance(steps := _value(job, "steps"), SequenceNode) - for step in steps.value - if isinstance(step, MappingNode) - and isinstance(name := _value(step, "name"), ScalarNode) - and name.value == step_name - ] - if len(matches) != 1: - raise ValueError(f"expected one mise step named {step_name!r}; found {len(matches)}") - step = matches[0] - uses = _value(step, "uses") - if ( - not isinstance(uses, ScalarNode) - or not uses.value.startswith("jdx/mise-action@") - or not _hex(uses.value.removeprefix("jdx/mise-action@"), 40) - ): - raise ValueError("selected step must use one SHA-pinned jdx/mise-action") - inputs = _value(step, "with") - if not isinstance(inputs, MappingNode): - raise TypeError("selected step must contain one with mapping") - values = {key: _value(inputs, key) for key in ("version", "sha256")} - step_start, step_end = _span(step) - if any( - not (step_start <= _span(node)[0] < _span(node)[1] <= step_end) - for node in (inputs, *values.values()) - if node is not None - ): - raise ValueError("selected mise step uses YAML aliases outside its own mapping") - if any(_occurrences(root, node) != 1 for node in (inputs, *values.values()) if node is not None): - raise ValueError("selected mise inputs are aliased elsewhere in the workflow") - if not all(isinstance(node, ScalarNode) and node.style in (None, "'", '"') for node in values.values()) or not ( - _VERSION.fullmatch(cast(ScalarNode, values["version"]).value) - and _hex(cast(ScalarNode, values["sha256"]).value, 64) - ): - raise ValueError("selected mise step needs one literal version and sha256") - current = cast(ScalarNode, values["version"]).value - if tuple(map(int, current.split("."))) > tuple(map(int, version.split("."))): - raise ValueError(f"selected mise {version} is older than the current pin {current}") - updated = source - for key, value in sorted( - (("version", version), ("sha256", expected_sha256)), - key=lambda item: _span(cast(ScalarNode, values[item[0]]))[0], - reverse=True, - ): - node = cast(ScalarNode, values[key]) - literal = f"{node.style}{value}{node.style}" if node.style else value - start, end = _span(node) - updated = f"{updated[:start]}{literal}{updated[end:]}" - if updated == source: - return False - descriptor, name = tempfile.mkstemp(dir=workflow.parent, prefix=f".{workflow.name}.") - try: - with os.fdopen(descriptor, "w", newline="") as stream: - stream.write(updated) - os.chmod(name, stat.S_IMODE(workflow.stat().st_mode)) - os.replace(name, workflow) - finally: - Path(name).unlink(missing_ok=True) - return True - - -def _value(mapping: MappingNode, key: str) -> Node | None: - matches = [value for name, value in mapping.value if isinstance(name, ScalarNode) and name.value == key] - if len(matches) > 1: - raise ValueError(f"duplicate YAML key: {key}") - return cast(Node, matches[0]) if matches else None - - -def _hex(value: str, length: int) -> bool: - return len(value) == length and all(character in "0123456789abcdef" for character in value) - - -def _span(node: Node) -> tuple[int, int]: - return cast(Mark, node.start_mark).index, cast(Mark, node.end_mark).index - - -def _occurrences(root: Node, target: Node, ancestors: frozenset[int] = frozenset()) -> int: - count = int(root is target) - if id(root) in ancestors: - return count - if isinstance(root, MappingNode): - children = (node for pair in root.value for node in pair) - elif isinstance(root, SequenceNode): - children = iter(root.value) - else: - return count - return count + sum(_occurrences(node, target, ancestors | {id(root)}) for node in children) - - -def _write_outputs(path: Path, **values: str) -> None: - with path.open("a") as stream: - for name, value in values.items(): - stream.write(f"{name}={value}\n") - - -if __name__ == "__main__": - main() - - -__all__ = ["main"] From 213497883a37cd11925df42a03a1e48899265518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 12:42:59 -0700 Subject: [PATCH 05/35] =?UTF-8?q?=F0=9F=94=A5=20feat(release)!:=20stop=20r?= =?UTF-8?q?unning=20prek=20on=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-tagged-source no longer installs release-check or runs the cooled hooks. The release patch is already checked with prek before the tag is pushed. --- README.md | 20 +++++++++----------- changelog.d/+mise-pin.breaking.md | 4 ++-- changelog.d/+tag-prek.breaking.md | 2 ++ check-tagged-source/action.yml | 9 +-------- 4 files changed, 14 insertions(+), 21 deletions(-) create mode 100644 changelog.d/+tag-prek.breaking.md diff --git a/README.md b/README.md index 2deff0a..ffaf25f 100644 --- a/README.md +++ b/README.md @@ -157,11 +157,10 @@ run `prek`. The shared prepare and tagged validator run `refresh-release-lock` in `check` mode before release checks or publishing. `check-tagged-source` bundles the tagged checks for a custom tag publisher: it requires `GITHUB_REF` to be the tag of -the checkout on `main`, checks the locks, installs `release-check`, and then runs `prek run --all-files` with the -seven-day cutoff. It takes `keep-uv-lock` and the secondary lock inputs below, and needs a full-history checkout. Pass -`keep-uv-lock: true` when the repository commits a native `uv.lock`. For a secondary Nab lock, pass its path, selected -groups, selection type, and matrix inputs to both workflows. A stale primary, native, or secondary lock stops the -release before artifacts are built. +the checkout on `main` and checks the locks. It runs no hooks. It takes `keep-uv-lock` and the secondary lock inputs +below, and needs a full-history checkout. Pass `keep-uv-lock: true` when the repository commits a native `uv.lock`. For +a secondary Nab lock, pass its path, selected groups, selection type, and matrix inputs to both workflows. A stale +primary, native, or secondary lock stops the release before artifacts are built. For example, a tagged source job that also publishes from a PGO lock checks both files before installing build tools: @@ -211,9 +210,8 @@ prepare-towncrier ──► upload patch ──► validate artifact The tag starts the caller's publisher. Keep PyPI trusted publishing in that top-level workflow, in a separate job with `id-token: write` because -[PyPI disallows reusable workflows](https://docs.pypi.org/trusted-publishers/troubleshooting/). Run `prek` in a separate -read-only job alongside the tagged-source build, and make publishing depend on both. Repositories with the standard `uv` -wheel and sdist build can call `validate-python-release.yaml` in the tag publisher. The +[PyPI disallows reusable workflows](https://docs.pypi.org/trusted-publishers/troubleshooting/). Repositories with the +standard `uv` wheel and sdist build can call `validate-python-release.yaml` in the tag publisher. The [self-release workflow](.github/workflows/release.yaml) shows the GitHub Release path; callers keep their ecosystem uploads. The read-only build workflow checks the tagged source. The tag caller renders the Release body and has the only Contents write job that creates the GitHub Release. @@ -236,9 +234,9 @@ build: The `build` job must grant `actions: read` and `pull-requests: read` as well as `contents: read`. The tagged hook age check has needed them since 3.0.1, and GitHub refuses to start the workflow when the caller grants less. The caller -needs `release-check` for prek and a `build` group for backend constraints. `source` defaults to `.`. Use -`artifact-prefix` when one tag produces more than one distribution path; each call then writes a distinct -`python-package-distributions` artifact. A caller pinned to a workflow commit runs that commit's local Actions. +needs a `build` group for backend constraints. `source` defaults to `.`. Use `artifact-prefix` when one tag produces +more than one distribution path; each call then writes a distinct `python-package-distributions` artifact. A caller +pinned to a workflow commit runs that commit's local Actions. Publish only after the reusable workflow succeeds. Download the distribution with pinned `download-artifact` [v8.0.2](https://github.com/actions/download-artifact/blob/9000827ccba6bdab643e8b6fd33ac0654aef8333/action.yml#L37-L45), diff --git a/changelog.d/+mise-pin.breaking.md b/changelog.d/+mise-pin.breaking.md index 18eb42f..25b64c6 100644 --- a/changelog.d/+mise-pin.breaking.md +++ b/changelog.d/+mise-pin.breaking.md @@ -1,2 +1,2 @@ -Remove the `refresh-mise-pin` Action. Callers that pinned a mise binary with it keep `jdx/mise-action`'s default -release age instead. +Remove the `refresh-mise-pin` Action. Callers that pinned a mise binary with it keep `jdx/mise-action`'s default release +age instead. diff --git a/changelog.d/+tag-prek.breaking.md b/changelog.d/+tag-prek.breaking.md new file mode 100644 index 0000000..022c518 --- /dev/null +++ b/changelog.d/+tag-prek.breaking.md @@ -0,0 +1,2 @@ +Stop running `prek` on the tagged source. `check-tagged-source` now checks only the tag and the locks and no longer +installs the caller's `release-check` group; the release patch is still checked with `prek` before it is pushed. diff --git a/check-tagged-source/action.yml b/check-tagged-source/action.yml index 14160f2..61d104d 100644 --- a/check-tagged-source/action.yml +++ b/check-tagged-source/action.yml @@ -1,5 +1,5 @@ name: Check tagged source -description: Check the tag and the project lock of a tagged checkout, then run its cooled hooks +description: Check the tag and the project lock of a tagged checkout inputs: keep-uv-lock: description: Check a committed native uv.lock alongside pylock.toml @@ -45,10 +45,3 @@ runs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} - - uses: $/install-locked-tools - with: - groups: release-check - venv: ${{ runner.temp }}/source-check-tools - - name: Run tagged source hooks - shell: bash - run: '"$GITHUB_ACTION_PATH/../tools/cooled_prek.sh" run --all-files' From 0c820341cae18e651b828a58dd149b7b6ac5f69f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 12:52:19 -0700 Subject: [PATCH 06/35] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20feat(lock)!:=20gener?= =?UTF-8?q?ate=20the=20primary=20lock=20with=20nab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refresh-release-lock now runs nab lock --all-groups --all-extras --no-cache, with --upgrade on refresh, and compares a regenerated lock in check mode. Every nab step unsets SSL_CERT_FILE and SSL_CERT_DIR. The release-creator locks path, the uv resolver steps and group, the keep-uv-lock plumbing, the reviewed cutoff table with its check, expiry and patch verifier, and lock_groups.py go. check_lock_groups.py keeps the PyPI and cooling checks and honours the caller's own [tool.nab] uploaded-prior-to overrides. The updater writer accepts only pylock.toml and the secondary lock. Tests read a copy of the 4.0.1 shared lock as caller data. --- .../workflows/prepare-towncrier-release.yaml | 5 - .github/workflows/upgrade-release-tools.yaml | 10 +- .../workflows/validate-python-release.yaml | 5 - README.md | 57 +- changelog.d/+cutoff-table.breaking.md | 3 + changelog.d/+keep-uv-lock.breaking.md | 3 + changelog.d/+nab-primary-lock.breaking.md | 4 + changelog.d/+writer-allowlist.breaking.md | 2 + check-tagged-source/action.yml | 4 - pyproject.toml | 1 - refresh-release-lock/action.yml | 80 +- tests/conftest.py | 13 +- tests/export_build_constraints/test_export.py | 29 +- tests/fixtures/caller/pylock.toml | 4851 +++++++++++++++++ tests/fixtures/caller/pyproject.toml | 93 + tests/install_locked_tools/test_install.py | 256 +- .../test_workspace_sources.py} | 41 +- .../test_check_nab_lock.py | 149 + tests/refresh_release_lock/test_cutoffs.py | 806 --- tests/refresh_release_lock/test_provider.py | 141 - tests/refresh_release_lock/test_refresh.py | 199 + .../test_resolver_upgrade.py | 211 - tests/upgrade_release_tools/test_upgrade.py | 57 +- tests/validate_news/test_news.py | 24 +- tools/check_lock_groups.py | 113 +- tools/check_recent_cutoffs.py | 39 - tools/expire_cutoffs.py | 37 - tools/lock_groups.py | 77 - tools/nab_cutoffs.py | 211 - tools/refresh_uv_resolver.py | 56 - tools/verify_cutoff_patch.py | 79 - validate-news/action.yml | 6 - verify-cutoff-patch/action.yml | 8 - 33 files changed, 5662 insertions(+), 2008 deletions(-) create mode 100644 changelog.d/+cutoff-table.breaking.md create mode 100644 changelog.d/+keep-uv-lock.breaking.md create mode 100644 changelog.d/+nab-primary-lock.breaking.md create mode 100644 changelog.d/+writer-allowlist.breaking.md create mode 100644 tests/fixtures/caller/pylock.toml create mode 100644 tests/fixtures/caller/pyproject.toml rename tests/{validate_news/test_lock_groups.py => install_locked_tools/test_workspace_sources.py} (72%) create mode 100644 tests/refresh_release_lock/test_check_nab_lock.py delete mode 100644 tests/refresh_release_lock/test_cutoffs.py delete mode 100644 tests/refresh_release_lock/test_provider.py create mode 100644 tests/refresh_release_lock/test_refresh.py delete mode 100644 tests/refresh_release_lock/test_resolver_upgrade.py delete mode 100644 tools/check_recent_cutoffs.py delete mode 100644 tools/expire_cutoffs.py delete mode 100644 tools/lock_groups.py delete mode 100644 tools/nab_cutoffs.py delete mode 100644 tools/refresh_uv_resolver.py delete mode 100644 tools/verify_cutoff_patch.py delete mode 100644 verify-cutoff-patch/action.yml diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index bc92d93..061ef80 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -18,10 +18,6 @@ on: description: Install release-creator from the tagged checkout for its own release type: boolean default: false - keep-uv-lock: - description: Check a committed native uv.lock alongside pylock.toml - type: boolean - default: false additional-lock: description: Secondary Nab lock to check before release preparation type: string @@ -86,7 +82,6 @@ jobs: - uses: $/refresh-release-lock with: mode: check - keep-uv-lock: ${{ inputs.keep-uv-lock }} additional-lock: ${{ inputs.additional-lock }} additional-lock-groups: ${{ inputs.additional-lock-groups }} additional-lock-selection: ${{ inputs.additional-lock-selection }} diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index c3d3e9c..0a0b0f1 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -26,10 +26,6 @@ on: description: Space-separated implementations for the secondary lock type: string default: "" - keep-uv-lock: - description: Refresh and check uv.lock alongside pylock.toml - type: boolean - default: false commit-message: description: Commit message for the refreshed dependency files type: string @@ -75,7 +71,6 @@ jobs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} - keep-uv-lock: ${{ inputs.keep-uv-lock }} - id: upload uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: steps.lock.outputs.changed == 'true' @@ -104,7 +99,6 @@ jobs: - name: Check artifact patch paths env: ADDITIONAL_LOCK: ${{ inputs.additional-lock }} - KEEP_UV_LOCK: ${{ inputs.keep-uv-lock }} run: | set -euo pipefail if [[ -n "$ADDITIONAL_LOCK" && @@ -117,8 +111,7 @@ jobs: GIT_INDEX_FILE="$RUNNER_TEMP/lock.index" \ git diff --cached --name-only --no-renames -z >"$RUNNER_TEMP/lock-paths" while IFS= read -r -d '' path; do - if [[ "$path" != pylock.toml && "$path" != pyproject.toml && "$path" != "$ADDITIONAL_LOCK" && - ("$path" != uv.lock || "$KEEP_UV_LOCK" != true) ]]; then + if [[ "$path" != pylock.toml && "$path" != "$ADDITIONAL_LOCK" ]]; then echo "::error::unexpected lock patch path: $path" >&2 exit 1 fi @@ -136,7 +129,6 @@ jobs: exit 1 fi done <"$RUNNER_TEMP/dependency-modes" - - uses: $/verify-cutoff-patch - name: Commit refreshed tools env: COMMIT_MESSAGE: ${{ inputs.commit-message }} diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index de1d9f2..bbe27b1 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -14,10 +14,6 @@ on: description: Prefix for distribution artifacts in a multi-package release default: "" type: string - keep-uv-lock: - description: Check a committed native uv.lock alongside pylock.toml - default: false - type: boolean additional-lock: description: Secondary Nab lock to check against the tagged project default: "" @@ -64,7 +60,6 @@ jobs: enable-cache: false - uses: $/check-tagged-source with: - keep-uv-lock: ${{ inputs.keep-uv-lock }} additional-lock: ${{ inputs.additional-lock }} additional-lock-groups: ${{ inputs.additional-lock-groups }} additional-lock-selection: ${{ inputs.additional-lock-selection }} diff --git a/README.md b/README.md index ffaf25f..460e774 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,11 @@ dependencies fresh; release jobs install only the selected release groups. The A tracked files unchanged. Source builds use constraints generated from the lock's `build` group under the runner's temporary directory. +Every selected package must come from PyPI with the recorded hashes and upload times, and must have been uploaded at +least seven days before the lock was created. A package that the caller's `[tool.nab.packages.]` or +`[[tool.nab.package-rules]]` table gives an `uploaded-prior-to` override may be as new as that override allows: a +timestamp, a `PnD` duration before the lock's creation, or `false` for no cutoff. + Select the groups each job needs. The shared workflows use `release-news release-shell`, `release-hook-check release-notes lock-reader`, and `release-prepare project-runtime` or `release-validate project-runtime` for release-creator's own build. A full lock records each group and extra, but a new @@ -93,14 +98,12 @@ The Action expects a pull request base ref and a Markdown or reStructuredText ch ## Refresh release tools Call `upgrade-release-tools.yaml` from a scheduled or manual workflow. Its read-only job refreshes the universal -`pylock.toml`. `release-creator locks` exports runtime dependencies, all extras, and all dependency groups with a -seven-day package cutoff. Reviewed exceptions live in `[tool.release-creator.locks.exclude-newer-package]`. Both lock -checks and weekly refresh install uv from the shared `release-resolver` group, so a setup-uv default change cannot alter -the generated lock. In github-workflows, the weekly updater also refreshes this direct pin and the lock together; -consumer locks use their pinned shared resolver. The job uploads a patch, which may change `pylock.toml`, an explicitly -configured secondary Nab lock, and `pyproject.toml` for expired reviewed cutoffs. The writer checks the patch before -applying it and verifies cutoff removals before minting the App token. It then opens or updates one dependency PR from -`automation/update-release-tools` to `main`. A lock update runs the normal PR checks before merge. +`pylock.toml` with `nab lock --all-groups --all-extras --no-cache --upgrade`, which records runtime dependencies, all +extras, and all dependency groups. The project's `[tool.nab]` table sets the cutoff and any per-package exception; the +caller adds and removes those exceptions itself. uv only installs tools and never resolves the lock. The job uploads a +patch, which may change only `pylock.toml` and an explicitly configured secondary Nab lock. The writer checks the patch +before minting the App token. It then opens or updates one dependency PR from `automation/update-release-tools` to +`main`. A lock update runs the normal PR checks before merge. The writer uses a repository-only release App with Contents and Pull requests write access. Install the App on this repository, set `RELEASE_APP_CLIENT_ID` as a repository variable, and store `RELEASE_APP_PRIVATE_KEY` in the @@ -125,30 +128,28 @@ jobs: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} ``` -`pylock.toml` must come from `release-creator locks`, which records every dependency group and extra; a Nab primary lock -fails with a request to relock. Tests install test, type, and documentation tools afresh to detect upstream dependency -changes. The updater also refreshes a caller's secondary Nab build-matrix lock when `additional-lock` and its matrix -inputs are set. +`pylock.toml` must come from `nab lock --all-groups --all-extras`; relock a `release-creator locks` lock with Nab before +re-pinning. Tests install test, type, and documentation tools afresh to detect upstream dependency changes. The updater +also refreshes a caller's secondary Nab build-matrix lock when `additional-lock` and its matrix inputs are set. The optional `commit-message`, `pull-request-title`, and `pull-request-body` inputs retain existing PR wording. The -workflow updates a committed `uv.lock` only when `keep-uv-lock: true`; pass that input to `validate-news` too. Both -locks are then checked with the same resolver and allowed in the dependency patch. The default leaves `uv.lock` alone. -Build constraints are generated from the lock's `build` group at runtime. +workflow never updates a committed `uv.lock`. Build constraints are generated from the lock's `build` group at runtime. For a second lock such as `pylock.build.toml`, set `additional-lock`, `additional-matrix-python`, `additional-matrix-platforms`, and `additional-matrix-implementations`. `additional-lock-groups` selects dependency groups in that lock; with no groups, the updater selects build requirements. Set `additional-lock-selection: project` for a lock that selects the project's runtime dependencies instead. The lock must already exist, and the updater writes -both locks in the same dependency PR. The writer accepts only those locks, an opted-in `uv.lock`, and `pyproject.toml`. -Pass the same `additional-lock` and matrix inputs to `validate-news` in the PR workflow. It checks changes to either -lock against the project, including a PR that changes only the secondary lock. +both locks in the same dependency PR. The writer accepts only those locks. Pass the same `additional-lock` and matrix +inputs to `validate-news` in the PR workflow. It checks changes to either lock against the project, including a PR that +changes only the secondary lock. `refresh-release-lock` is the read-only half of the updater. In `refresh` mode, it upgrades the full primary lock and -writes `$RUNNER_TEMP/lock.patch` if a lock or reviewed cutoff changes. In `check` mode, it regenerates at the primary -lock's recorded creation time and fails if the checked-in files differ. `validate-news` calls this mode when a PR -changes release inputs, including a workspace member's `pyproject.toml` or a secondary lock. The resulting PR's normal -formatting job runs `prek`; the writer checks patch paths and modes. Its `changed` output is `true` only when the -refresh produces a patch. Neither mode pushes a branch or requests a write token. +writes `$RUNNER_TEMP/lock.patch` if a lock changes. In `check` mode, it regenerates the primary lock without upgrading +and fails if anything other than Nab's recorded command line differs. Every Nab run unsets `SSL_CERT_FILE` and +`SSL_CERT_DIR`, so Nab uses its own certificate store. `validate-news` calls this mode when a PR changes release inputs, +including a workspace member's `pyproject.toml` or a secondary lock. The resulting PR's normal formatting job runs +`prek`; the writer checks patch paths and modes. Its `changed` output is `true` only when the refresh produces a patch. +Neither mode pushes a branch or requests a write token. Every shared `prek` command passes `--config .pre-commit-config.yaml`. Without it, prek prefers a root `prek.toml` and also runs nested configurations, which no guard checks. `prepare-towncrier` and `validate-release-patch` therefore @@ -157,10 +158,9 @@ run `prek`. The shared prepare and tagged validator run `refresh-release-lock` in `check` mode before release checks or publishing. `check-tagged-source` bundles the tagged checks for a custom tag publisher: it requires `GITHUB_REF` to be the tag of -the checkout on `main` and checks the locks. It runs no hooks. It takes `keep-uv-lock` and the secondary lock inputs -below, and needs a full-history checkout. Pass `keep-uv-lock: true` when the repository commits a native `uv.lock`. For -a secondary Nab lock, pass its path, selected groups, selection type, and matrix inputs to both workflows. A stale -primary, native, or secondary lock stops the release before artifacts are built. +the checkout on `main` and checks the locks. It runs no hooks. It takes the secondary lock inputs below and needs a +full-history checkout. For a secondary Nab lock, pass its path, selected groups, selection type, and matrix inputs to +both workflows. A stale primary or secondary lock stops the release before artifacts are built. For example, a tagged source job that also publishes from a PGO lock checks both files before installing build tools: @@ -175,9 +175,6 @@ For example, a tagged source job that also publishes from a PGO lock checks both additional-matrix-implementations: cpython pypy ``` -For a committed native uv lock, set `keep-uv-lock: true` in this step. The full project lock and `uv.lock` must then -agree with the same project configuration. - ## Start with a caller The [release preparation workflow](.github/workflows/prepare-release.yaml) in this repository calls the reusable diff --git a/changelog.d/+cutoff-table.breaking.md b/changelog.d/+cutoff-table.breaking.md new file mode 100644 index 0000000..6b059d8 --- /dev/null +++ b/changelog.d/+cutoff-table.breaking.md @@ -0,0 +1,3 @@ +Remove the reviewed cutoff table, the recent cutoff check, the cutoff expiry, and the `verify-cutoff-patch` Action. +Callers own their per-package `uploaded-prior-to` exceptions in `[tool.nab]`; the release lock check honours them, and +nothing checks or expires them. diff --git a/changelog.d/+keep-uv-lock.breaking.md b/changelog.d/+keep-uv-lock.breaking.md new file mode 100644 index 0000000..1d44dc5 --- /dev/null +++ b/changelog.d/+keep-uv-lock.breaking.md @@ -0,0 +1,3 @@ +Remove the `keep-uv-lock` input of `refresh-release-lock`, `validate-news`, `check-tagged-source`, +`prepare-towncrier-release.yaml`, `upgrade-release-tools.yaml`, and `validate-python-release.yaml`. A committed +`uv.lock` is no longer refreshed or checked. diff --git a/changelog.d/+nab-primary-lock.breaking.md b/changelog.d/+nab-primary-lock.breaking.md new file mode 100644 index 0000000..63aebde --- /dev/null +++ b/changelog.d/+nab-primary-lock.breaking.md @@ -0,0 +1,4 @@ +Generate the primary `pylock.toml` with `nab lock --all-groups --all-extras --no-cache`, adding `--upgrade` in +`refresh` mode, and compare a regenerated lock in `check` mode. The `release-creator locks` path and the uv resolver +steps are gone, so relock a `release-creator locks` lock with Nab before re-pinning. Nab runs without `SSL_CERT_FILE` +and `SSL_CERT_DIR`. diff --git a/changelog.d/+writer-allowlist.breaking.md b/changelog.d/+writer-allowlist.breaking.md new file mode 100644 index 0000000..7faaae9 --- /dev/null +++ b/changelog.d/+writer-allowlist.breaking.md @@ -0,0 +1,2 @@ +Accept only `pylock.toml` and the configured secondary lock in the `upgrade-release-tools.yaml` patch. +`pyproject.toml` and `uv.lock` changes are rejected. diff --git a/check-tagged-source/action.yml b/check-tagged-source/action.yml index 61d104d..9187a4d 100644 --- a/check-tagged-source/action.yml +++ b/check-tagged-source/action.yml @@ -1,9 +1,6 @@ name: Check tagged source description: Check the tag and the project lock of a tagged checkout inputs: - keep-uv-lock: - description: Check a committed native uv.lock alongside pylock.toml - default: "false" additional-lock: description: Secondary Nab lock to check against the tagged project default: "" @@ -38,7 +35,6 @@ runs: - uses: $/refresh-release-lock with: mode: check - keep-uv-lock: ${{ inputs.keep-uv-lock }} additional-lock: ${{ inputs.additional-lock }} additional-lock-groups: ${{ inputs.additional-lock-groups }} additional-lock-selection: ${{ inputs.additional-lock-selection }} diff --git a/pyproject.toml b/pyproject.toml index 479adbd..9fad360 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,6 @@ build = [ "Cython>=3.0", "setuptools>=80" ] lock-tools = [ "nab==0.0.22", "tomlkit>=0.13" ] lock-reader = [ "certifi>=2026.7.22", "packaging>=26.3" ] release-notes = [ "release-creator>=2.0.0" ] -release-resolver = [ "uv==0.13.5" ] release-mise-editor = [ "pyyaml>=6" ] release-news = [ "release-creator>=2.0.0", "towncrier>=26.9" ] release-prepare = [ "release-creator>=2.0.0", "towncrier>=26.9", "prek>=0.2" ] diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index be30d3c..38b234c 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -1,5 +1,5 @@ name: Refresh a release-tool lock -description: Refresh or check a release-creator project lock and optional Nab matrix lock +description: Refresh or check a Nab project lock and optional Nab matrix lock inputs: additional-lock: description: Optional secondary pylock basename for a separate build matrix @@ -22,12 +22,9 @@ inputs: mode: description: Refresh the lock or check that the committed lock is current default: refresh - keep-uv-lock: - description: Refresh and check uv.lock alongside pylock.toml for projects that use both - default: "false" outputs: changed: - description: Whether the lock or expired package cutoffs differ from the checkout + description: Whether the locks differ from the checkout value: ${{ steps.lock.outputs.changed }} runs: using: composite @@ -41,7 +38,6 @@ runs: MATRIX_PYTHON: ${{ inputs.additional-matrix-python }} MATRIX_PLATFORMS: ${{ inputs.additional-matrix-platforms }} MATRIX_IMPLEMENTATIONS: ${{ inputs.additional-matrix-implementations }} - KEEP_UV_LOCK: ${{ inputs.keep-uv-lock }} run: | set -euo pipefail if [[ -z "$ADDITIONAL_LOCK" && @@ -50,80 +46,33 @@ runs: echo '::error::secondary matrix inputs require additional-lock' >&2 exit 1 fi - python3 "$GITHUB_ACTION_PATH/../tools/lock_groups.py" pylock.toml - if [[ "$KEEP_UV_LOCK" == true ]] && ! git ls-files --error-unmatch uv.lock >/dev/null 2>&1; then - echo '::error::keep-uv-lock requires a committed uv.lock' >&2 - exit 1 - fi - uses: $/install-locked-tools - if: inputs.mode == 'refresh' || inputs.additional-lock != '' with: groups: lock-tools lock-source: shared venv: ${{ runner.temp }}/lock-tools - - uses: $/install-locked-tools - with: - groups: release-notes - lock-source: shared - venv: ${{ runner.temp }}/project-lock-tools - - uses: $/install-locked-tools - with: - groups: release-resolver - lock-source: shared - venv: ${{ runner.temp }}/release-resolver - - name: Check recent package cutoffs - shell: bash - run: python3 "$GITHUB_ACTION_PATH/../tools/check_recent_cutoffs.py" pyproject.toml - - name: Expire reviewed package cutoffs - if: inputs.mode == 'refresh' - shell: bash - run: >- - uv run --no-project --python "$RUNNER_TEMP/lock-tools" python "$GITHUB_ACTION_PATH/../tools/expire_cutoffs.py" pyproject.toml - - name: Widen the shared uv pin - if: inputs.mode == 'refresh' && github.repository == 'tox-dev/github-workflows' - shell: bash - run: >- - uv run --no-project --python "$RUNNER_TEMP/lock-tools" python "$GITHUB_ACTION_PATH/../tools/refresh_uv_resolver.py" widen pyproject.toml pylock.toml - name: Resolve all project groups and extras shell: bash env: MODE: ${{ inputs.mode }} - KEEP_UV_LOCK: ${{ inputs.keep-uv-lock }} run: | set -euo pipefail - export PATH="$RUNNER_TEMP/release-resolver/bin:$PATH" - options=() - if [[ "$KEEP_UV_LOCK" == true ]]; then options+=(--keep-uv-lock); fi + unset SSL_CERT_FILE SSL_CERT_DIR case "$MODE" in - check) uv run --no-project --python "$RUNNER_TEMP/project-lock-tools" release-creator locks --check "${options[@]}" ;; - refresh) uv run --no-project --python "$RUNNER_TEMP/project-lock-tools" release-creator locks "${options[@]}" ;; + check) + refresh=() + cp pylock.toml "$RUNNER_TEMP/lock-before.toml" + ;; + refresh) refresh=(--upgrade) ;; *) echo "::error::invalid lock mode: $MODE" >&2 exit 1 ;; esac - - name: Pin the updated uv resolver - if: inputs.mode == 'refresh' && github.repository == 'tox-dev/github-workflows' - shell: bash - run: >- - uv run --no-project --python "$RUNNER_TEMP/lock-tools" python "$GITHUB_ACTION_PATH/../tools/refresh_uv_resolver.py" pin pyproject.toml pylock.toml - - uses: $/install-locked-tools - if: inputs.mode == 'refresh' && github.repository == 'tox-dev/github-workflows' - with: - groups: release-resolver - venv: ${{ runner.temp }}/updated-release-resolver - - name: Resolve with the updated uv resolver - if: inputs.mode == 'refresh' && github.repository == 'tox-dev/github-workflows' - shell: bash - env: - KEEP_UV_LOCK: ${{ inputs.keep-uv-lock }} - run: | - set -euo pipefail - export PATH="$RUNNER_TEMP/updated-release-resolver/bin:$PATH" - options=() - if [[ "$KEEP_UV_LOCK" == true ]]; then options+=(--keep-uv-lock); fi - uv run --no-project --python "$RUNNER_TEMP/project-lock-tools" release-creator locks "${options[@]}" - uv run --no-project --python "$RUNNER_TEMP/project-lock-tools" release-creator locks --check "${options[@]}" + uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock --all-groups --all-extras --no-cache "${refresh[@]}" + if [[ "$MODE" == check ]]; then + python3 "$GITHUB_ACTION_PATH/../tools/check_nab_lock.py" "$RUNNER_TEMP/lock-before.toml" pylock.toml + fi - name: Resolve optional Nab matrix if: inputs.additional-lock != '' shell: bash @@ -137,6 +86,7 @@ runs: MATRIX_IMPLEMENTATIONS: ${{ inputs.additional-matrix-implementations }} run: | set -euo pipefail + unset SSL_CERT_FILE SSL_CERT_DIR case "$MODE" in check) refresh=() ;; refresh) refresh=(--upgrade) ;; @@ -204,12 +154,10 @@ runs: env: MODE: ${{ inputs.mode }} ADDITIONAL_LOCK: ${{ inputs.additional-lock }} - KEEP_UV_LOCK: ${{ inputs.keep-uv-lock }} run: | set -euo pipefail - paths=(pylock.toml pyproject.toml) + paths=(pylock.toml) if [[ -n "$ADDITIONAL_LOCK" ]]; then paths+=("$ADDITIONAL_LOCK"); fi - if [[ "$KEEP_UV_LOCK" == true ]]; then paths+=(uv.lock); fi if [[ "$MODE" == check ]]; then git diff --exit-code -- "${paths[@]}" echo 'changed=false' >>"$GITHUB_OUTPUT" diff --git a/tests/conftest.py b/tests/conftest.py index 671b05d..4d0ef7c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,6 +22,7 @@ from pytest_httpserver import HTTPServer from werkzeug.wrappers import Request, Response +CALLER: Final = Path(__file__).parent / "fixtures/caller" _PyPIFile = TypedDict( "_PyPIFile", {"filename": str, "url": str, "hashes": dict[str, str], "upload-time": str}, @@ -51,6 +52,15 @@ def github_api(tmp_path: Path) -> Iterator[tuple[HTTPServer, dict[str, str]]]: server.stop() +@pytest.fixture +def caller(tmp_path: Path) -> Path: + root = tmp_path / "caller" + root.mkdir() + for name in ("pylock.toml", "pyproject.toml"): + shutil.copyfile(CALLER / name, root / name) + return root + + @pytest.fixture(scope="session") def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: source = Path(__file__).resolve().parents[1] @@ -85,7 +95,7 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: @pytest.fixture(scope="session") def pypi_metadata(tmp_path_factory: pytest.TempPathFactory) -> Path: - listings = _lock_metadata(Path(__file__).resolve().parents[1] / "pylock.toml") + listings = _lock_metadata(CALLER / "pylock.toml") for filename, url, digest, uploaded in ( ( "six-1.16.0-py2.py3-none-any.whl", @@ -584,6 +594,7 @@ def _lock_metadata(lock: Path) -> dict[str, list[_PyPIFile]]: __all__ = [ + "CALLER", "action", "run", ] diff --git a/tests/export_build_constraints/test_export.py b/tests/export_build_constraints/test_export.py index ceaa111..ba0dfff 100644 --- a/tests/export_build_constraints/test_export.py +++ b/tests/export_build_constraints/test_export.py @@ -12,13 +12,12 @@ import pytest import tomlkit -from conftest import action +from conftest import CALLER, action def test_export_build_constraints_uses_checked_in_locks(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] - (tmp_path / "pylock.toml").write_bytes((source / "pylock.toml").read_bytes()) - (tmp_path / "pyproject.toml").write_bytes((source / "pyproject.toml").read_bytes()) + (tmp_path / "pylock.toml").write_bytes((CALLER / "pylock.toml").read_bytes()) + (tmp_path / "pyproject.toml").write_bytes((CALLER / "pyproject.toml").read_bytes()) outputs: dict[str, str] = {} result = action(tmp_path, tmp_path, action_scripts, "export-build-constraints", outputs, version="") assert result.returncode == 0, result.stderr @@ -33,9 +32,8 @@ def test_export_build_constraints_uses_checked_in_locks(tmp_path: Path, action_s def test_export_build_constraints_accepts_tool_only_project_without_build_group( tmp_path: Path, action_scripts: Path ) -> None: - source = Path(__file__).resolve().parents[2] - (tmp_path / "pyproject.toml").write_bytes((source / "pyproject.toml").read_bytes()) - document = tomlkit.parse((source / "pylock.toml").read_text()) + (tmp_path / "pyproject.toml").write_bytes((CALLER / "pyproject.toml").read_bytes()) + document = tomlkit.parse((CALLER / "pylock.toml").read_text()) document["dependency-groups"].remove("build") (tmp_path / "pylock.toml").write_text(tomlkit.dumps(document)) outputs: dict[str, str] = {} @@ -48,7 +46,7 @@ def test_export_build_constraints_accepts_tool_only_project_without_build_group( def test_export_build_constraints_writes_empty_file_without_build_selection(tmp_path: Path) -> None: source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((source / "pylock.toml").read_text()) + document = tomlkit.parse((CALLER / "pylock.toml").read_text()) document["dependency-groups"].remove("build") lock = tmp_path / "pylock.toml" lock.write_text(tomlkit.dumps(document)) @@ -84,7 +82,7 @@ def test_export_build_constraints_covers_release_runners(tmp_path: Path, platfor "--targets", "3.15.0:cpython", str(output), - str(source / "pylock.toml"), + str(CALLER / "pylock.toml"), ], text=True, capture_output=True, @@ -102,7 +100,7 @@ def test_export_build_constraints_prefers_compatible_caller_build_pin( tmp_path: Path, version: str, accepted: bool ) -> None: source = Path(__file__).resolve().parents[2] - shared = source / "pylock.toml" + shared = CALLER / "pylock.toml" caller = tmp_path / "pylock.toml" document = tomlkit.parse(shared.read_text()) for package in document["packages"]: @@ -119,7 +117,7 @@ def test_export_build_constraints_prefers_compatible_caller_build_pin( sys.executable, str(source / "tools/build_constraints.py"), "--shared-policy", - str(source / "pyproject.toml"), + str(CALLER / "pyproject.toml"), str(output), str(shared), str(caller), @@ -202,7 +200,7 @@ def test_export_build_constraints_rejects_invalid_arguments( source = Path(__file__).resolve().parents[2] lock = tmp_path / "pylock.build.toml" write_matrix_lock(lock) - paths = {"output": str(tmp_path / "constraints.txt"), "lock": str(lock), "project": str(source / "pyproject.toml")} + paths = {"output": str(tmp_path / "constraints.txt"), "lock": str(lock), "project": str(CALLER / "pyproject.toml")} script = source / "tools/build_constraints.py" command = runpy.run_path(str(script), run_name="build_constraints_imported") monkeypatch.setattr(sys, "argv", [str(script), *(argument.format_map(paths) for argument in arguments)]) @@ -258,7 +256,7 @@ def test_export_build_constraints_rejects_untrusted_build_packages(tmp_path: Pat def test_export_build_constraints_rejects_conflicting_versions_without_policy(tmp_path: Path) -> None: source = Path(__file__).resolve().parents[2] - shared = source / "pylock.toml" + shared = CALLER / "pylock.toml" caller = tmp_path / "pylock.toml" document = tomlkit.parse(shared.read_text()) package = next(package for package in document["packages"] if package["name"] == "setuptools") @@ -430,11 +428,10 @@ def test_export_build_constraints_uses_full_python_version(tmp_path: Path, versi def write_published_matrix_lock(path: Path) -> str: write_matrix_lock(path) - source = Path(__file__).resolve().parents[2] - (path.parent / "pyproject.toml").write_bytes((source / "pyproject.toml").read_bytes()) + (path.parent / "pyproject.toml").write_bytes((CALLER / "pyproject.toml").read_bytes()) package = next( package - for package in tomllib.loads((source / "pylock.toml").read_text())["packages"] + for package in tomllib.loads((CALLER / "pylock.toml").read_text())["packages"] if package["name"] == "packaging" ) document = tomlkit.parse(path.read_text()) diff --git a/tests/fixtures/caller/pylock.toml b/tests/fixtures/caller/pylock.toml new file mode 100644 index 0000000..7d05603 --- /dev/null +++ b/tests/fixtures/caller/pylock.toml @@ -0,0 +1,4851 @@ +lock-version = "1.0" +created-by = "release-creator (uv)" +requires-python = ">=3.15" +extras = [] +dependency-groups = [ + "project-runtime", + "build", + "lock-tools", + "lock-reader", + "release-notes", + "release-resolver", + "release-mise-editor", + "release-news", + "release-prepare", + "release-validate", + "release-check", + "release-attestations", + "release-shell", + "release-hooks", + "release-hook-check", + "test", +] +default-groups = [] + +[[packages]] +name = "annotated-types" +version = "0.8.0" +marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz" +upload-time = 2026-07-23 20:16:13+00:00 +size = 15893 + +[packages.sdist.hashes] +sha256 = "13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl" +upload-time = 2026-07-23 20:16:12+00:00 +size = 13427 + +[packages.wheels.hashes] +sha256 = "f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0" + +[[packages]] +name = "argcomplete" +version = "3.7.2" +marker = "\"release-hook-check\" in dependency_groups or \"release-shell\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/87/6f/5a73f04007ca950701765949209f068da628bd11f9c2da287278ce91e0ee/argcomplete-3.7.2.tar.gz" +upload-time = 2026-08-06 04:53:21+00:00 +size = 74473 + +[packages.sdist.hashes] +sha256 = "aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl" +upload-time = 2026-08-06 04:53:20+00:00 +size = 43294 + +[packages.wheels.hashes] +sha256 = "6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19" + +[[packages]] +name = "attrs" +version = "26.1.0" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz" +upload-time = 2026-03-19 14:22:25+00:00 +size = 952055 + +[packages.sdist.hashes] +sha256 = "d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl" +upload-time = 2026-03-19 14:22:23+00:00 +size = 67548 + +[packages.wheels.hashes] +sha256 = "c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309" + +[[packages]] +name = "build" +version = "1.6.1" +marker = "\"lock-tools\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/bd/67/4898a44ea4f3f8e213b0954ec0aa0a16971d62a6212d6ea3931e97115b99/build-1.6.1.tar.gz" +upload-time = 2026-09-10 07:56:00+00:00 +size = 113427 + +[packages.sdist.hashes] +sha256 = "51cc11666391ab6f092070437ac747002ff46f3e4113a3622177ee6b488bfc53" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/ad/9b/9fb3585dabcd73a1b2a6267f63f62649347c9e6d072c9fde365b105abb2c/build-1.6.1-py3-none-any.whl" +upload-time = 2026-09-10 07:55:59+00:00 +size = 31179 + +[packages.wheels.hashes] +sha256 = "ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7" + +[[packages]] +name = "cachetools" +version = "7.2.3" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/09/88/27fe0785c7a9aa285c753db0b4ea967adbf76128fede66379c35a4b8d054/cachetools-7.2.3.tar.gz" +upload-time = 2026-10-17 11:41:25+00:00 +size = 43727 + +[packages.sdist.hashes] +sha256 = "e6fafd06de4a5bd915ff7d247588ea497e28c9a568c62db8678d9a5f11515a9a" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/fb/dd/044062b61d94c59c29ad0da6918e6a21106f5b115096e9c87081cdd96167/cachetools-7.2.3-py3-none-any.whl" +upload-time = 2026-10-17 11:41:24+00:00 +size = 18352 + +[packages.wheels.hashes] +sha256 = "85ef9d2703918bedf7f88caa0b06bb3bb7a71360eb0b05c20796b89fede6e30c" + +[[packages]] +name = "certifi" +version = "2026.7.22" +marker = "\"lock-reader\" in dependency_groups or \"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz" +upload-time = 2026-07-22 03:35:12+00:00 +size = 138112 + +[packages.sdist.hashes] +sha256 = "741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl" +upload-time = 2026-07-22 03:35:11+00:00 +size = 136983 + +[packages.wheels.hashes] +sha256 = "62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775" + +[[packages]] +name = "cffi" +version = "2.1.1" +marker = "(\"release-attestations\" in dependency_groups and (platform_python_implementation != 'PyPy')) or (\"test\" in dependency_groups and (platform_python_implementation != 'PyPy'))" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz" +upload-time = 2026-08-03 21:21:18+00:00 +size = 530807 + +[packages.sdist.hashes] +sha256 = "dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl" +upload-time = 2026-08-03 21:20:48+00:00 +size = 194043 + +[packages.wheels.hashes] +sha256 = "dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl" +upload-time = 2026-08-03 21:20:49+00:00 +size = 196737 + +[packages.wheels.hashes] +sha256 = "da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl" +upload-time = 2026-08-03 21:20:50+00:00 +size = 184933 + +[packages.wheels.hashes] +sha256 = "fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl" +upload-time = 2026-08-03 21:20:52+00:00 +size = 185002 + +[packages.wheels.hashes] +sha256 = "2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" +upload-time = 2026-08-03 21:20:53+00:00 +size = 222271 + +[packages.wheels.hashes] +sha256 = "507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl" +upload-time = 2026-08-03 21:20:54+00:00 +size = 209919 + +[packages.wheels.hashes] +sha256 = "246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl" +upload-time = 2026-08-03 21:20:56+00:00 +size = 208529 + +[packages.wheels.hashes] +sha256 = "471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-08-03 21:20:57+00:00 +size = 221630 + +[packages.wheels.hashes] +sha256 = "aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl" +upload-time = 2026-08-03 21:20:58+00:00 +size = 225134 + +[packages.wheels.hashes] +sha256 = "42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl" +upload-time = 2026-08-03 21:20:59+00:00 +size = 223197 + +[packages.wheels.hashes] +sha256 = "cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl" +upload-time = 2026-08-03 21:21:14+00:00 +size = 177683 + +[packages.wheels.hashes] +sha256 = "4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl" +upload-time = 2026-08-03 21:21:16+00:00 +size = 187897 + +[packages.wheels.hashes] +sha256 = "e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl" +upload-time = 2026-08-03 21:21:17+00:00 +size = 182935 + +[packages.wheels.hashes] +sha256 = "27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl" +upload-time = 2026-08-03 21:21:01+00:00 +size = 188464 + +[packages.wheels.hashes] +sha256 = "c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl" +upload-time = 2026-08-03 21:21:02+00:00 +size = 188262 + +[packages.wheels.hashes] +sha256 = "4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" +upload-time = 2026-08-03 21:21:03+00:00 +size = 223779 + +[packages.wheels.hashes] +sha256 = "937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl" +upload-time = 2026-08-03 21:21:04+00:00 +size = 211520 + +[packages.wheels.hashes] +sha256 = "df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl" +upload-time = 2026-08-03 21:21:06+00:00 +size = 210673 + +[packages.wheels.hashes] +sha256 = "a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-08-03 21:21:07+00:00 +size = 223835 + +[packages.wheels.hashes] +sha256 = "363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl" +upload-time = 2026-08-03 21:21:08+00:00 +size = 226705 + +[packages.wheels.hashes] +sha256 = "770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl" +upload-time = 2026-08-03 21:21:09+00:00 +size = 225539 + +[packages.wheels.hashes] +sha256 = "7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl" +upload-time = 2026-08-03 21:21:11+00:00 +size = 182707 + +[packages.wheels.hashes] +sha256 = "06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl" +upload-time = 2026-08-03 21:21:12+00:00 +size = 193772 + +[packages.wheels.hashes] +sha256 = "d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl" +upload-time = 2026-08-03 21:21:13+00:00 +size = 186360 + +[packages.wheels.hashes] +sha256 = "d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692" + +[[packages]] +name = "charset-normalizer" +version = "3.5.2" +marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz" +upload-time = 2026-09-30 04:39:23+00:00 +size = 172659 + +[packages.sdist.hashes] +sha256 = "39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl" +upload-time = 2026-09-30 04:37:28+00:00 +size = 367676 + +[packages.wheels.hashes] +sha256 = "87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26" + +[[packages.wheels]] +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" +upload-time = 2026-09-30 04:37:30+00:00 +size = 248414 + +[packages.wheels.hashes] +sha256 = "9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364" + +[[packages.wheels]] +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" +upload-time = 2026-09-30 04:37:31+00:00 +size = 244775 + +[packages.wheels.hashes] +sha256 = "498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253" + +[[packages.wheels]] +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" +upload-time = 2026-09-30 04:37:33+00:00 +size = 273877 + +[packages.wheels.hashes] +sha256 = "e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0" + +[[packages.wheels]] +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" +upload-time = 2026-09-30 04:37:35+00:00 +size = 269455 + +[packages.wheels.hashes] +sha256 = "def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc" + +[[packages.wheels]] +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" +upload-time = 2026-09-30 04:37:37+00:00 +size = 257336 + +[packages.wheels.hashes] +sha256 = "3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229" + +[[packages.wheels]] +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" +upload-time = 2026-09-30 04:37:39+00:00 +size = 257584 + +[packages.wheels.hashes] +sha256 = "04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl" +upload-time = 2026-09-30 04:37:41+00:00 +size = 251854 + +[packages.wheels.hashes] +sha256 = "183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl" +upload-time = 2026-09-30 04:37:42+00:00 +size = 248767 + +[packages.wheels.hashes] +sha256 = "16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl" +upload-time = 2026-09-30 04:37:44+00:00 +size = 274479 + +[packages.wheels.hashes] +sha256 = "7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl" +upload-time = 2026-09-30 04:37:46+00:00 +size = 258310 + +[packages.wheels.hashes] +sha256 = "ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl" +upload-time = 2026-09-30 04:37:48+00:00 +size = 271100 + +[packages.wheels.hashes] +sha256 = "df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl" +upload-time = 2026-09-30 04:37:50+00:00 +size = 259936 + +[packages.wheels.hashes] +sha256 = "f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl" +upload-time = 2026-09-30 04:37:51+00:00 +size = 186301 + +[packages.wheels.hashes] +sha256 = "281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl" +upload-time = 2026-09-30 04:37:53+00:00 +size = 210858 + +[packages.wheels.hashes] +sha256 = "89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl" +upload-time = 2026-09-30 04:37:55+00:00 +size = 200309 + +[packages.wheels.hashes] +sha256 = "6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl" +upload-time = 2026-09-30 04:37:56+00:00 +size = 395343 + +[packages.wheels.hashes] +sha256 = "915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1" + +[[packages.wheels]] +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" +upload-time = 2026-09-30 04:37:58+00:00 +size = 250114 + +[packages.wheels.hashes] +sha256 = "65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b" + +[[packages.wheels]] +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" +upload-time = 2026-09-30 04:38:00+00:00 +size = 239997 + +[packages.wheels.hashes] +sha256 = "b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f" + +[[packages.wheels]] +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" +upload-time = 2026-09-30 04:38:02+00:00 +size = 272313 + +[packages.wheels.hashes] +sha256 = "e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3" + +[[packages.wheels]] +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" +upload-time = 2026-09-30 04:38:04+00:00 +size = 269986 + +[packages.wheels.hashes] +sha256 = "124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e" + +[[packages.wheels]] +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" +upload-time = 2026-09-30 04:38:06+00:00 +size = 260202 + +[packages.wheels.hashes] +sha256 = "28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9" + +[[packages.wheels]] +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" +upload-time = 2026-09-30 04:38:08+00:00 +size = 259083 + +[packages.wheels.hashes] +sha256 = "58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl" +upload-time = 2026-09-30 04:38:10+00:00 +size = 253081 + +[packages.wheels.hashes] +sha256 = "443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl" +upload-time = 2026-09-30 04:38:12+00:00 +size = 248712 + +[packages.wheels.hashes] +sha256 = "58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl" +upload-time = 2026-09-30 04:38:14+00:00 +size = 274176 + +[packages.wheels.hashes] +sha256 = "1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl" +upload-time = 2026-09-30 04:38:15+00:00 +size = 259413 + +[packages.wheels.hashes] +sha256 = "ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl" +upload-time = 2026-09-30 04:38:17+00:00 +size = 269434 + +[packages.wheels.hashes] +sha256 = "5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl" +upload-time = 2026-09-30 04:38:19+00:00 +size = 262716 + +[packages.wheels.hashes] +sha256 = "51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl" +upload-time = 2026-09-30 04:38:21+00:00 +size = 197159 + +[packages.wheels.hashes] +sha256 = "5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl" +upload-time = 2026-09-30 04:38:22+00:00 +size = 221322 + +[packages.wheels.hashes] +sha256 = "a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl" +upload-time = 2026-09-30 04:38:24+00:00 +size = 209192 + +[packages.wheels.hashes] +sha256 = "749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl" +upload-time = 2026-09-30 04:38:26+00:00 +size = 331815 + +[packages.wheels.hashes] +sha256 = "4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd" + +[[packages.wheels]] +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" +upload-time = 2026-09-30 04:38:28+00:00 +size = 253276 + +[packages.wheels.hashes] +sha256 = "1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7" + +[[packages.wheels]] +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" +upload-time = 2026-09-30 04:38:29+00:00 +size = 241239 + +[packages.wheels.hashes] +sha256 = "d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f" + +[[packages.wheels]] +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" +upload-time = 2026-09-30 04:38:31+00:00 +size = 231121 + +[packages.wheels.hashes] +sha256 = "0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93" + +[[packages.wheels]] +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" +upload-time = 2026-09-30 04:38:33+00:00 +size = 260350 + +[packages.wheels.hashes] +sha256 = "838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade" + +[[packages.wheels]] +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" +upload-time = 2026-09-30 04:38:34+00:00 +size = 255430 + +[packages.wheels.hashes] +sha256 = "2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0" + +[[packages.wheels]] +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" +upload-time = 2026-09-30 04:38:36+00:00 +size = 250612 + +[packages.wheels.hashes] +sha256 = "0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl" +upload-time = 2026-09-30 04:38:38+00:00 +size = 242083 + +[packages.wheels.hashes] +sha256 = "1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl" +upload-time = 2026-09-30 04:38:39+00:00 +size = 232738 + +[packages.wheels.hashes] +sha256 = "304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl" +upload-time = 2026-09-30 04:38:41+00:00 +size = 260703 + +[packages.wheels.hashes] +sha256 = "9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl" +upload-time = 2026-09-30 04:38:42+00:00 +size = 247622 + +[packages.wheels.hashes] +sha256 = "01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl" +upload-time = 2026-09-30 04:38:44+00:00 +size = 257500 + +[packages.wheels.hashes] +sha256 = "7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl" +upload-time = 2026-09-30 04:38:46+00:00 +size = 255100 + +[packages.wheels.hashes] +sha256 = "9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl" +upload-time = 2026-09-30 04:38:47+00:00 +size = 174499 + +[packages.wheels.hashes] +sha256 = "01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl" +upload-time = 2026-09-30 04:38:49+00:00 +size = 200092 + +[packages.wheels.hashes] +sha256 = "9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl" +upload-time = 2026-09-30 04:38:51+00:00 +size = 294363 + +[packages.wheels.hashes] +sha256 = "40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl" +upload-time = 2026-09-30 04:39:21+00:00 +size = 68872 + +[packages.wheels.hashes] +sha256 = "b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685" + +[[packages]] +name = "check-wheel-contents" +version = "0.6.3" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/95/37/2b9e0d38c2f668791ffe8b97711185c364d91c027e47f189c371c2348d18/check_wheel_contents-0.6.3.tar.gz" +upload-time = 2025-08-02 14:01:45+00:00 +size = 586023 + +[packages.sdist.hashes] +sha256 = "10e6939e2fe4e6ce1edf2ff6ec6157808677e80782e78021ae139dd88473a442" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/be/05/f39fde9f31ef80b285ef5822fad4ddabf73fec62a1f02c5beb4b2f328972/check_wheel_contents-0.6.3-py3-none-any.whl" +upload-time = 2025-08-02 14:01:43+00:00 +size = 27541 + +[packages.wheels.hashes] +sha256 = "5ae39c8c434b972f0740d04610759168590713175aab584b012b1b84f6771874" + +[[packages]] +name = "click" +version = "8.5.0" +marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz" +upload-time = 2026-08-26 13:33:14+00:00 +size = 382235 + +[packages.sdist.hashes] +sha256 = "ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl" +upload-time = 2026-08-26 13:33:12+00:00 +size = 125251 + +[packages.wheels.hashes] +sha256 = "255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360" + +[[packages]] +name = "colorama" +version = "0.4.6" +marker = "\"test\" in dependency_groups or (\"lock-tools\" in dependency_groups and (os_name == 'nt'))" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz" +upload-time = 2022-10-25 02:36:22+00:00 +size = 27697 + +[packages.sdist.hashes] +sha256 = "08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl" +upload-time = 2022-10-25 02:36:20+00:00 +size = 25335 + +[packages.wheels.hashes] +sha256 = "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" + +[[packages]] +name = "coverage" +version = "7.16.2" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz" +upload-time = 2026-09-27 12:29:01+00:00 +size = 971999 + +[packages.sdist.hashes] +sha256 = "ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/4c/57/52935003c3f627ba6e5203d7179aad32448c10899663a30336aba8e81a2c/coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl" +upload-time = 2026-09-27 12:27:59+00:00 +size = 224140 + +[packages.wheels.hashes] +sha256 = "414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl" +upload-time = 2026-09-27 12:28:01+00:00 +size = 224449 + +[packages.wheels.hashes] +sha256 = "00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24" + +[[packages.wheels]] +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" +upload-time = 2026-09-27 12:28:03+00:00 +size = 256060 + +[packages.wheels.hashes] +sha256 = "4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09" + +[[packages.wheels]] +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" +upload-time = 2026-09-27 12:28:05+00:00 +size = 259106 + +[packages.wheels.hashes] +sha256 = "8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843" + +[[packages.wheels]] +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" +upload-time = 2026-09-27 12:28:06+00:00 +size = 260674 + +[packages.wheels.hashes] +sha256 = "9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1" + +[[packages.wheels]] +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" +upload-time = 2026-09-27 12:28:08+00:00 +size = 263139 + +[packages.wheels.hashes] +sha256 = "aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/14/5e/7c805ac9a32606de1399bd7e9bd375aa2f973dc61b12680d9e6403c2e891/coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl" +upload-time = 2026-09-27 12:28:10+00:00 +size = 256591 + +[packages.wheels.hashes] +sha256 = "9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/ab/9e/76f1ed129a2daf658a3ea17122824cf2e3b91fea0460d8d3664fc5a61018/coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl" +upload-time = 2026-09-27 12:28:12+00:00 +size = 258708 + +[packages.wheels.hashes] +sha256 = "80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/5a/b7/8d62e75f48b527619239a65294f842d4b7fd02a0839d43ae1de80184e2df/coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl" +upload-time = 2026-09-27 12:28:14+00:00 +size = 256562 + +[packages.wheels.hashes] +sha256 = "7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b8/8d/0a15f95c3afb78e947c52644786ba4bc9de259905687dd720d5e6fae2e76/coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl" +upload-time = 2026-09-27 12:28:17+00:00 +size = 261077 + +[packages.wheels.hashes] +sha256 = "44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/25/00/88389987305a47d732866c07c8a500000ab574df9505e3114ac69c8d027f/coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl" +upload-time = 2026-09-27 12:28:19+00:00 +size = 256034 + +[packages.wheels.hashes] +sha256 = "59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/92/02/34d079d4952ad461bde037d353f9a6e037a7edc45fe0f9ee8781ff73f028/coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl" +upload-time = 2026-09-27 12:28:21+00:00 +size = 258034 + +[packages.wheels.hashes] +sha256 = "066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/f6/d8/3e59a62879285b464ec1b10fd824fbc1af9ce66e842cd39974f80a0becc4/coverage-7.16.2-cp315-cp315-win32.whl" +upload-time = 2026-09-27 12:28:23+00:00 +size = 226252 + +[packages.wheels.hashes] +sha256 = "893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/f4/e1/128026e1b2836e9ad6b219207ba9edf1c5e0088a7869e23088aee7fbbe7a/coverage-7.16.2-cp315-cp315-win_amd64.whl" +upload-time = 2026-09-27 12:28:25+00:00 +size = 226728 + +[packages.wheels.hashes] +sha256 = "01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/a8/f4/c9fa8e7cf525ca7748ac52b0ee89331d13fe09808e45c679830708782e90/coverage-7.16.2-cp315-cp315-win_arm64.whl" +upload-time = 2026-09-27 12:28:27+00:00 +size = 226648 + +[packages.wheels.hashes] +sha256 = "967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/a2/13/e96b045447a856666f36f9c653e2a80bdaa732aaaf72412b19aa2c26a473/coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl" +upload-time = 2026-09-27 12:28:29+00:00 +size = 224858 + +[packages.wheels.hashes] +sha256 = "98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/23/90/087f6ad1bd3df059632ca3407a4e6552ed1053ee35354de0a771acf35423/coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl" +upload-time = 2026-09-27 12:28:31+00:00 +size = 225139 + +[packages.wheels.hashes] +sha256 = "3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55" + +[[packages.wheels]] +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" +upload-time = 2026-09-27 12:28:33+00:00 +size = 265090 + +[packages.wheels.hashes] +sha256 = "fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2" + +[[packages.wheels]] +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" +upload-time = 2026-09-27 12:28:35+00:00 +size = 268254 + +[packages.wheels.hashes] +sha256 = "eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2" + +[[packages.wheels]] +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" +upload-time = 2026-09-27 12:28:36+00:00 +size = 270695 + +[packages.wheels.hashes] +sha256 = "444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1" + +[[packages.wheels]] +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" +upload-time = 2026-09-27 12:28:38+00:00 +size = 271853 + +[packages.wheels.hashes] +sha256 = "a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/38/29/bf8072b1b8bd5f2de8b21460a404460b1a2b97e80a9464c78ec0271f6199/coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl" +upload-time = 2026-09-27 12:28:40+00:00 +size = 265644 + +[packages.wheels.hashes] +sha256 = "8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/7c/2f/0aecb8721be5cdeb8afd9d6d9f6b463f074e4d8d37f00f4c42442522709f/coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl" +upload-time = 2026-09-27 12:28:42+00:00 +size = 268530 + +[packages.wheels.hashes] +sha256 = "396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/ab/0b/92b4b7628268ee711249958e68fc0328779bd3d9a7ab4715379465aedb84/coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl" +upload-time = 2026-09-27 12:28:44+00:00 +size = 265154 + +[packages.wheels.hashes] +sha256 = "9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/7b/d9/41c95c1ab29b3dcd357cd1227181d1c98185632aca41ce670ce671b23a43/coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl" +upload-time = 2026-09-27 12:28:46+00:00 +size = 269799 + +[packages.wheels.hashes] +sha256 = "fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/80/07/ebeb259aa5362b033a137b86d7274ff4b109d59be8cc9913889b783bf75a/coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl" +upload-time = 2026-09-27 12:28:48+00:00 +size = 265196 + +[packages.wheels.hashes] +sha256 = "bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b2/18/8437620f90d023680a072eee02f968055f3658bbfb7d386d0ea34cfb7f30/coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl" +upload-time = 2026-09-27 12:28:51+00:00 +size = 267266 + +[packages.wheels.hashes] +sha256 = "dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/28/6c/f08e8ee4293e6434035424180bef4d45e028e8ecc006c61bf9453e74405e/coverage-7.16.2-cp315-cp315t-win32.whl" +upload-time = 2026-09-27 12:28:53+00:00 +size = 226583 + +[packages.wheels.hashes] +sha256 = "251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/f7/fd/3f939c2847f4a72c20cff8b1ac33da78ea91a2d38d9b43336e60db719103/coverage-7.16.2-cp315-cp315t-win_amd64.whl" +upload-time = 2026-09-27 12:28:55+00:00 +size = 227374 + +[packages.wheels.hashes] +sha256 = "2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/5a/35/b98cdc354c952402132e675a87f2cc3227fb68f959c84aaa491fbe15933d/coverage-7.16.2-cp315-cp315t-win_arm64.whl" +upload-time = 2026-09-27 12:28:57+00:00 +size = 227064 + +[packages.wheels.hashes] +sha256 = "b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl" +upload-time = 2026-09-27 12:28:59+00:00 +size = 215754 + +[packages.wheels.hashes] +sha256 = "11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f" + +[[packages]] +name = "cryptography" +version = "50.0.2" +marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz" +upload-time = 2026-09-30 15:30:04+00:00 +size = 880623 + +[packages.sdist.hashes] +sha256 = "7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl" +upload-time = 2026-09-30 14:43:44+00:00 +size = 3914904 + +[packages.wheels.hashes] +sha256 = "fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" +upload-time = 2026-09-30 14:43:47+00:00 +size = 4731146 + +[packages.wheels.hashes] +sha256 = "79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-09-30 14:43:49+00:00 +size = 4719841 + +[packages.wheels.hashes] +sha256 = "630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl" +upload-time = 2026-09-30 14:43:50+00:00 +size = 4738340 + +[packages.wheels.hashes] +sha256 = "f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl" +upload-time = 2026-09-30 14:43:52+00:00 +size = 5367029 + +[packages.wheels.hashes] +sha256 = "a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl" +upload-time = 2026-09-30 14:43:55+00:00 +size = 4753050 + +[packages.wheels.hashes] +sha256 = "4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl" +upload-time = 2026-09-30 14:43:57+00:00 +size = 4376724 + +[packages.wheels.hashes] +sha256 = "ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl" +upload-time = 2026-09-30 14:43:59+00:00 +size = 4737859 + +[packages.wheels.hashes] +sha256 = "87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl" +upload-time = 2026-09-30 14:44:01+00:00 +size = 5324103 + +[packages.wheels.hashes] +sha256 = "f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl" +upload-time = 2026-09-30 14:44:04+00:00 +size = 4752576 + +[packages.wheels.hashes] +sha256 = "9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl" +upload-time = 2026-09-30 14:44:06+00:00 +size = 4870819 + +[packages.wheels.hashes] +sha256 = "25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl" +upload-time = 2026-09-30 14:44:09+00:00 +size = 5030152 + +[packages.wheels.hashes] +sha256 = "85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl" +upload-time = 2026-09-30 14:44:11+00:00 +size = 3824692 + +[packages.wheels.hashes] +sha256 = "7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl" +upload-time = 2026-09-30 14:44:41+00:00 +size = 4133708 + +[packages.wheels.hashes] +sha256 = "edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" +upload-time = 2026-09-30 14:44:43+00:00 +size = 4956267 + +[packages.wheels.hashes] +sha256 = "d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-09-30 14:44:45+00:00 +size = 4966465 + +[packages.wheels.hashes] +sha256 = "f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl" +upload-time = 2026-09-30 14:44:48+00:00 +size = 4959356 + +[packages.wheels.hashes] +sha256 = "e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl" +upload-time = 2026-09-30 14:44:50+00:00 +size = 5548822 + +[packages.wheels.hashes] +sha256 = "b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl" +upload-time = 2026-09-30 14:44:53+00:00 +size = 5001199 + +[packages.wheels.hashes] +sha256 = "58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl" +upload-time = 2026-09-30 14:44:55+00:00 +size = 4629333 + +[packages.wheels.hashes] +sha256 = "d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl" +upload-time = 2026-09-30 14:44:59+00:00 +size = 4958822 + +[packages.wheels.hashes] +sha256 = "fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl" +upload-time = 2026-09-30 14:45:02+00:00 +size = 5506351 + +[packages.wheels.hashes] +sha256 = "cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl" +upload-time = 2026-09-30 14:45:05+00:00 +size = 5000859 + +[packages.wheels.hashes] +sha256 = "e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl" +upload-time = 2026-09-30 15:29:15+00:00 +size = 5092151 + +[packages.wheels.hashes] +sha256 = "6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl" +upload-time = 2026-09-30 15:29:18+00:00 +size = 5286120 + +[packages.wheels.hashes] +sha256 = "c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl" +upload-time = 2026-09-30 15:29:20+00:00 +size = 4111557 + +[packages.wheels.hashes] +sha256 = "c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl" +upload-time = 2026-09-30 15:29:22+00:00 +size = 3943588 + +[packages.wheels.hashes] +sha256 = "0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" +upload-time = 2026-09-30 15:29:24+00:00 +size = 4756166 + +[packages.wheels.hashes] +sha256 = "c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-09-30 15:29:26+00:00 +size = 4749145 + +[packages.wheels.hashes] +sha256 = "ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl" +upload-time = 2026-09-30 15:29:28+00:00 +size = 4763638 + +[packages.wheels.hashes] +sha256 = "dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl" +upload-time = 2026-09-30 15:29:30+00:00 +size = 5382217 + +[packages.wheels.hashes] +sha256 = "58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl" +upload-time = 2026-09-30 15:29:32+00:00 +size = 4781387 + +[packages.wheels.hashes] +sha256 = "f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl" +upload-time = 2026-09-30 15:29:34+00:00 +size = 4403790 + +[packages.wheels.hashes] +sha256 = "9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl" +upload-time = 2026-09-30 15:29:36+00:00 +size = 4764319 + +[packages.wheels.hashes] +sha256 = "0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl" +upload-time = 2026-09-30 15:29:39+00:00 +size = 5338560 + +[packages.wheels.hashes] +sha256 = "a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl" +upload-time = 2026-09-30 15:29:41+00:00 +size = 4780973 + +[packages.wheels.hashes] +sha256 = "1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl" +upload-time = 2026-09-30 15:29:43+00:00 +size = 4897738 + +[packages.wheels.hashes] +sha256 = "7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl" +upload-time = 2026-09-30 15:29:44+00:00 +size = 5058280 + +[packages.wheels.hashes] +sha256 = "ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl" +upload-time = 2026-09-30 15:29:46+00:00 +size = 3854095 + +[packages.wheels.hashes] +sha256 = "4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de" + +[[packages]] +name = "cython" +version = "3.3.0" +marker = "\"build\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/a9/d8/4981ef716ad0e3ff0d3ef383aefc6b03c4a88dee33b272bf8e0d833001ca/cython-3.3.0.tar.gz" +upload-time = 2026-08-22 05:16:39+00:00 +size = 3727515 + +[packages.sdist.hashes] +sha256 = "eed0d93fbca7087f143b42c34b05a825849bdf17f101572c2105acfa49aa88b8" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/ea/45/7f6988070013e16918e39b1b3dab9c5f2c8e404253a7fd10ee685bbd6902/cython-3.3.0-cp315-cp315-macosx_11_0_arm64.whl" +upload-time = 2026-08-22 05:17:32+00:00 +size = 3177681 + +[packages.wheels.hashes] +sha256 = "596e8df019372a2cd417805015022d42cb8ee4e1803ccdc11ed00e451625fb66" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/6e/5d/afb6866ab10236bb208dff0f172ea4b397c9693c5250280c4d9d26057218/cython-3.3.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" +upload-time = 2026-08-22 05:17:34+00:00 +size = 3328312 + +[packages.wheels.hashes] +sha256 = "4a36c34d1950845b8ac148653b07cdc62421a4b0d9abfcc849e69f1c4ff9919d" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/c9/aa/4c0b6773ecf6bc1ec6cda7db8312a566611b330fb6dae87d740e44a47822/cython-3.3.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +upload-time = 2026-08-22 05:17:36+00:00 +size = 3454285 + +[packages.wheels.hashes] +sha256 = "b447f6906e0555f05dc4742ef1f99091b1e5d9aa9f16616e772fbf9ff6271616" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/44/bb/3e2631122f96300723d6fc42b9cf65550bdcda570a6ad5c4e0226e2e787c/cython-3.3.0-cp315-cp315-win_amd64.whl" +upload-time = 2026-08-22 05:17:38+00:00 +size = 2924709 + +[packages.wheels.hashes] +sha256 = "b55c72e8eccdd508c8de3cf3bbc543aafbb3bf6a518e1ee20358d3241cd780ef" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/14/59/bc1a84b434cb5bebb0cd6f50da8f239d35a5c141b20fdeafc2817fd87778/cython-3.3.0-cp39-abi3-macosx_10_9_x86_64.whl" +upload-time = 2026-08-22 05:17:40+00:00 +size = 3063660 + +[packages.wheels.hashes] +sha256 = "e0d2713d2b292c826bc21dc8732bd9e47628103aa3764180c881e04b3fef95dc" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/ba/6d/542e32908fb421d88354f327ed6450e14240f9825d25393065bc65f4723f/cython-3.3.0-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl" +upload-time = 2026-08-22 05:17:43+00:00 +size = 3358395 + +[packages.wheels.hashes] +sha256 = "169e56fd411f4cd5bba51c82f8239421d547a846099db2b261e4aed48ba9f51f" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/9c/7c/ddaf197bc65b581e1891657940bc4f7cb1f740e822115e828920b3a119ce/cython-3.3.0-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl" +upload-time = 2026-08-22 05:17:44+00:00 +size = 3041760 + +[packages.wheels.hashes] +sha256 = "29f38ebafdf23e3da2516f40c4d065da38bfe002181bf93e2b8cf1262449aba6" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/19/a7/ae5ec3e34d43da846ed4c425734752d83aae0dae49feb929f09c90fc9afa/cython-3.3.0-cp39-abi3-musllinux_1_2_aarch64.whl" +upload-time = 2026-08-22 05:17:46+00:00 +size = 3156152 + +[packages.wheels.hashes] +sha256 = "75c4ae8a6d3a5ccf3cdaba8ab32e6a8d0cd38e3a476aa7ac12df8f8171a8d570" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/31/44/c60b601fc43f0b08e9d6f14b94e0dd02eb0ca8d60f46e242ace7191ac1be/cython-3.3.0-cp39-abi3-musllinux_1_2_armv7l.whl" +upload-time = 2026-08-22 05:17:48+00:00 +size = 3046690 + +[packages.wheels.hashes] +sha256 = "b94fb5613b9fe34c27d13ec9972dc0dcd2a2155db2902e93921cadc162610a38" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b0/9e/d735c26ed907563d3365534006acb263651c2d3b87fee804f7a483dd1714/cython-3.3.0-cp39-abi3-musllinux_1_2_i686.whl" +upload-time = 2026-08-22 05:17:50+00:00 +size = 3373534 + +[packages.wheels.hashes] +sha256 = "c4558ba85849ab65dc57e10fd0efb13fabd9d3c09981a2566e18dec7cf47586a" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/e0/e8/aa7b4f3a28d6e8117c76e2cf78a0df7a503486cdf7243c5b53200c9533a1/cython-3.3.0-cp39-abi3-musllinux_1_2_x86_64.whl" +upload-time = 2026-08-22 05:17:52+00:00 +size = 3265110 + +[packages.wheels.hashes] +sha256 = "311a016369adfd1e0015c4f9819168fc0e518451d7efb4435c30d65a3a26d52b" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/9c/66/37892a8999d6bbd3f92d691a9701cb720c8ddd6171e16f5148eee6e8cb7f/cython-3.3.0-cp39-abi3-win32.whl" +upload-time = 2026-08-22 05:17:54+00:00 +size = 2587733 + +[packages.wheels.hashes] +sha256 = "90869072e50b7c8904fe1dd7810321ae901fd5637a6eec6646ed9c57f9eb1081" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/19/a2/5f4d305cbd4489d21570e5491ad5c483c478cdab032853e2125c280e3bd5/cython-3.3.0-cp39-abi3-win_arm64.whl" +upload-time = 2026-08-22 05:17:56+00:00 +size = 2608078 + +[packages.wheels.hashes] +sha256 = "dce56c26d388f00a19426371b6926bf2f77c5c03b71d5273e4556c68be98c2dd" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/bf/77/67b0b24e45073a699610e50f00c18474ff9b09ea29ecc95083bdf5e60acd/cython-3.3.0-py3-none-any.whl" +upload-time = 2026-08-22 05:16:36+00:00 +size = 1349151 + +[packages.wheels.hashes] +sha256 = "9b24b5c8cd536946b62086fcafee6d5509d3f549f72d553d2336af87ffbe0da1" + +[[packages]] +name = "distlib" +version = "0.4.4" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/68/bc/d36966de7304047a1001a7559fbf266c464bb5d8cc85464536ef8c9bcc32/distlib-0.4.4.tar.gz" +upload-time = 2026-10-16 13:56:34+00:00 +size = 621304 + +[packages.sdist.hashes] +sha256 = "ca4175b4ac7f71223cc30ed7248c45d2b38c2439d8ee6e498ab67ed63cf3508d" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/c1/2b/238f02a4927fa2e27aa995371d240a468b52e42eae44a678434aff736b38/distlib-0.4.4-py2.py3-none-any.whl" +upload-time = 2026-10-16 13:56:32+00:00 +size = 472099 + +[packages.wheels.hashes] +sha256 = "cf09d55b1e85a89576b036aeb71550bfd68320f3f21761ca329eadadad918638" + +[[packages]] +name = "dnspython" +version = "2.9.0" +marker = "\"release-attestations\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/ef/4a/50822184bd67cc6493f0fb6a880749158fcd31ab3fa07409acfd91f9fc85/dnspython-2.9.0.tar.gz" +upload-time = 2026-10-09 00:07:24+00:00 +size = 423560 + +[packages.sdist.hashes] +sha256 = "b44dc6b18f07a8b1c56676a19fbfdb5209415b046a9cece286baafa87ff3f7f1" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/10/02/cdcc9b7c051786a103c3b09e1003a82fa0c66bcb91ffbdabcfbf7b4163b9/dnspython-2.9.0-py3-none-any.whl" +upload-time = 2026-10-09 00:07:22+00:00 +size = 354822 + +[packages.wheels.hashes] +sha256 = "9a4aedb833c3c1b49214d04d44d3032ab7a9135f7c1d29a549b4ff78fd82fda9" + +[[packages]] +name = "docutils" +version = "0.23" +marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz" +upload-time = 2026-05-27 17:41:06+00:00 +size = 2303823 + +[packages.sdist.hashes] +sha256 = "746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl" +upload-time = 2026-05-27 17:40:58+00:00 +size = 634701 + +[packages.wheels.hashes] +sha256 = "25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea" + +[[packages]] +name = "email-validator" +version = "2.3.0" +marker = "\"release-attestations\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz" +upload-time = 2025-08-26 13:09:06+00:00 +size = 51238 + +[packages.sdist.hashes] +sha256 = "9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl" +upload-time = 2025-08-26 13:09:05+00:00 +size = 35604 + +[packages.wheels.hashes] +sha256 = "80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4" + +[[packages]] +name = "execnet" +version = "2.1.2" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz" +upload-time = 2025-11-12 09:56:37+00:00 +size = 166622 + +[packages.sdist.hashes] +sha256 = "63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl" +upload-time = 2025-11-12 09:56:36+00:00 +size = 40708 + +[packages.wheels.hashes] +sha256 = "67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec" + +[[packages]] +name = "filelock" +version = "4.2.1" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/d5/98/771d8ca889f220d7d7e352addf500666368f0fd081c6080a83767580c6e4/filelock-4.2.1.tar.gz" +upload-time = 2026-10-17 16:48:14+00:00 +size = 650997 + +[packages.sdist.hashes] +sha256 = "c4c937ccac2a813cee448e662a56366cfb374c0f8702e75f295c412cc1c5d5a3" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/28/00/db89d339bc4ecdc8087fa7b8607ba22ffc27bf96286f340c9620bea428b3/filelock-4.2.1-py3-none-any.whl" +upload-time = 2026-10-17 16:48:12+00:00 +size = 162455 + +[packages.wheels.hashes] +sha256 = "7be2662e9e826b9bba28afc0f843a123280d799a2ea44bb5d35ccec67905ae44" + +[[packages]] +name = "hatchling" +version = "1.32.4" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/f6/97/b5312f01a8c6daf729a9d272dd442e0c546dbcc630495788786c4b567ed0/hatchling-1.32.4.tar.gz" +upload-time = 2026-09-20 22:48:45+00:00 +size = 58065 + +[packages.sdist.hashes] +sha256 = "c4468f73144c054d2aab4ef0f0378c43b9878bf07f8ffd6b79690e970d375f07" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/5f/80/91f51f439c05d4ec4623c22928ce16a938d6d793bf709477830823497859/hatchling-1.32.4-py3-none-any.whl" +upload-time = 2026-09-20 22:48:44+00:00 +size = 78748 + +[packages.wheels.hashes] +sha256 = "08ecf7548fb48205e7f213d70c71e67b8271b7242093dc3f1da578b42c734a2c" + +[[packages]] +name = "id" +version = "1.6.1" +marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz" +upload-time = 2026-02-04 16:19:41+00:00 +size = 18088 + +[packages.sdist.hashes] +sha256 = "d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl" +upload-time = 2026-02-04 16:19:40+00:00 +size = 14689 + +[packages.wheels.hashes] +sha256 = "f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca" + +[[packages]] +name = "idna" +version = "3.20" +marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz" +upload-time = 2026-09-17 14:11:04+00:00 +size = 216463 + +[packages.sdist.hashes] +sha256 = "a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl" +upload-time = 2026-09-17 14:11:03+00:00 +size = 69583 + +[packages.wheels.hashes] +sha256 = "ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c" + +[[packages]] +name = "iniconfig" +version = "2.3.1" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz" +upload-time = 2026-10-06 22:48:38+00:00 +size = 21209 + +[packages.sdist.hashes] +sha256 = "67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl" +upload-time = 2026-10-06 22:48:36+00:00 +size = 7552 + +[packages.wheels.hashes] +sha256 = "9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7" + +[[packages]] +name = "installer" +version = "1.1.0" +marker = "\"lock-tools\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/56/d3/2fab33d15004d41ae8e7b5d3e310510f7f34a7e1dd032a3e60d8b38d8fba/installer-1.1.0.tar.gz" +upload-time = 2026-10-12 19:35:52+00:00 +size = 481257 + +[packages.sdist.hashes] +sha256 = "5adc0c01ce62d809989e246987cd68944fe5df8af562d0358f65b2ab15cb5de7" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d7/7a/63547a792a12148882205d79e07644091d5b7690f40c2bb9d41452a8bdc2/installer-1.1.0-py3-none-any.whl" +upload-time = 2026-10-12 19:35:51+00:00 +size = 464466 + +[packages.wheels.hashes] +sha256 = "f55f5728ff61d3dd54dbc5a397cf3f7307cb64b180c20b72afbebf01c9e2c8a0" + +[[packages]] +name = "jaraco-classes" +version = "3.4.0" +marker = "(\"test\" in dependency_groups and (platform_machine != 'ppc64le' and platform_machine != 's390x'))" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz" +upload-time = 2024-03-31 07:27:36+00:00 +size = 11780 + +[packages.sdist.hashes] +sha256 = "47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl" +upload-time = 2024-03-31 07:27:34+00:00 +size = 6777 + +[packages.wheels.hashes] +sha256 = "f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790" + +[[packages]] +name = "jaraco-context" +version = "6.1.2" +marker = "(\"test\" in dependency_groups and (platform_machine != 'ppc64le' and platform_machine != 's390x'))" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz" +upload-time = 2026-03-20 22:13:33+00:00 +size = 16801 + +[packages.sdist.hashes] +sha256 = "f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl" +upload-time = 2026-03-20 22:13:32+00:00 +size = 7871 + +[packages.wheels.hashes] +sha256 = "bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535" + +[[packages]] +name = "jaraco-functools" +version = "4.6.0" +marker = "(\"test\" in dependency_groups and (platform_machine != 'ppc64le' and platform_machine != 's390x'))" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz" +upload-time = 2026-07-14 01:28:02+00:00 +size = 20837 + +[packages.sdist.hashes] +sha256 = "880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl" +upload-time = 2026-07-14 01:28:01+00:00 +size = 11677 + +[packages.wheels.hashes] +sha256 = "99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30" + +[[packages]] +name = "jeepney" +version = "0.9.0" +marker = "(\"test\" in dependency_groups and (platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'))" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz" +upload-time = 2025-02-27 18:51:01+00:00 +size = 106758 + +[packages.sdist.hashes] +sha256 = "cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl" +upload-time = 2025-02-27 18:51:00+00:00 +size = 49010 + +[packages.wheels.hashes] +sha256 = "97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683" + +[[packages]] +name = "jinja2" +version = "3.1.6" +marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz" +upload-time = 2025-03-05 20:05:02+00:00 +size = 245115 + +[packages.sdist.hashes] +sha256 = "0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl" +upload-time = 2025-03-05 20:05:00+00:00 +size = 134899 + +[packages.wheels.hashes] +sha256 = "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" + +[[packages]] +name = "keyring" +version = "25.7.0" +marker = "(\"test\" in dependency_groups and (platform_machine != 'ppc64le' and platform_machine != 's390x'))" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz" +upload-time = 2025-11-16 16:26:09+00:00 +size = 63516 + +[packages.sdist.hashes] +sha256 = "fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl" +upload-time = 2025-11-16 16:26:08+00:00 +size = 39160 + +[packages.wheels.hashes] +sha256 = "be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f" + +[[packages]] +name = "markdown-it-py" +version = "4.2.0" +marker = "\"release-attestations\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz" +upload-time = 2026-05-07 12:08:28+00:00 +size = 82454 + +[packages.sdist.hashes] +sha256 = "04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl" +upload-time = 2026-05-07 12:08:27+00:00 +size = 91687 + +[packages.wheels.hashes] +sha256 = "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" + +[[packages]] +name = "markupsafe" +version = "3.0.4" +marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz" +upload-time = 2026-10-02 23:07:22+00:00 +size = 153777 + +[packages.sdist.hashes] +sha256 = "2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl" +upload-time = 2026-10-02 23:06:10+00:00 +size = 12791 + +[packages.wheels.hashes] +sha256 = "737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl" +upload-time = 2026-10-02 23:06:11+00:00 +size = 12527 + +[packages.wheels.hashes] +sha256 = "489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl" +upload-time = 2026-10-02 23:06:13+00:00 +size = 11602 + +[packages.wheels.hashes] +sha256 = "077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl" +upload-time = 2026-10-02 23:06:14+00:00 +size = 11817 + +[packages.wheels.hashes] +sha256 = "9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl" +upload-time = 2026-10-02 23:06:15+00:00 +size = 11446 + +[packages.wheels.hashes] +sha256 = "b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl" +upload-time = 2026-10-02 23:06:16+00:00 +size = 11719 + +[packages.wheels.hashes] +sha256 = "799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl" +upload-time = 2026-10-02 23:06:17+00:00 +size = 12075 + +[packages.wheels.hashes] +sha256 = "ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97" + +[[packages.wheels]] +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" +upload-time = 2026-10-02 23:06:18+00:00 +size = 24656 + +[packages.wheels.hashes] +sha256 = "4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b" + +[[packages.wheels]] +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" +upload-time = 2026-10-02 23:06:20+00:00 +size = 27737 + +[packages.wheels.hashes] +sha256 = "3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9" + +[[packages.wheels]] +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" +upload-time = 2026-10-02 23:06:21+00:00 +size = 25003 + +[packages.wheels.hashes] +sha256 = "04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653" + +[[packages.wheels]] +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" +upload-time = 2026-10-02 23:06:22+00:00 +size = 23111 + +[packages.wheels.hashes] +sha256 = "925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl" +upload-time = 2026-10-02 23:06:23+00:00 +size = 22403 + +[packages.wheels.hashes] +sha256 = "f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl" +upload-time = 2026-10-02 23:06:25+00:00 +size = 24083 + +[packages.wheels.hashes] +sha256 = "e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl" +upload-time = 2026-10-02 23:06:26+00:00 +size = 22931 + +[packages.wheels.hashes] +sha256 = "55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl" +upload-time = 2026-10-02 23:06:27+00:00 +size = 24299 + +[packages.wheels.hashes] +sha256 = "2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl" +upload-time = 2026-10-02 23:06:29+00:00 +size = 21948 + +[packages.wheels.hashes] +sha256 = "94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl" +upload-time = 2026-10-02 23:06:30+00:00 +size = 23150 + +[packages.wheels.hashes] +sha256 = "2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl" +upload-time = 2026-10-02 23:06:31+00:00 +size = 14269 + +[packages.wheels.hashes] +sha256 = "03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl" +upload-time = 2026-10-02 23:06:33+00:00 +size = 14496 + +[packages.wheels.hashes] +sha256 = "d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl" +upload-time = 2026-10-02 23:06:34+00:00 +size = 14361 + +[packages.wheels.hashes] +sha256 = "353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl" +upload-time = 2026-10-02 23:06:35+00:00 +size = 11760 + +[packages.wheels.hashes] +sha256 = "c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl" +upload-time = 2026-10-02 23:06:37+00:00 +size = 12108 + +[packages.wheels.hashes] +sha256 = "1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99" + +[[packages.wheels]] +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" +upload-time = 2026-10-02 23:06:38+00:00 +size = 25762 + +[packages.wheels.hashes] +sha256 = "02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002" + +[[packages.wheels]] +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" +upload-time = 2026-10-02 23:06:39+00:00 +size = 28400 + +[packages.wheels.hashes] +sha256 = "05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e" + +[[packages.wheels]] +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" +upload-time = 2026-10-02 23:06:41+00:00 +size = 25946 + +[packages.wheels.hashes] +sha256 = "be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c" + +[[packages.wheels]] +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" +upload-time = 2026-10-02 23:06:42+00:00 +size = 23634 + +[packages.wheels.hashes] +sha256 = "26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl" +upload-time = 2026-10-02 23:06:44+00:00 +size = 23488 + +[packages.wheels.hashes] +sha256 = "f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl" +upload-time = 2026-10-02 23:06:45+00:00 +size = 25037 + +[packages.wheels.hashes] +sha256 = "436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl" +upload-time = 2026-10-02 23:06:47+00:00 +size = 23543 + +[packages.wheels.hashes] +sha256 = "4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl" +upload-time = 2026-10-02 23:06:48+00:00 +size = 25282 + +[packages.wheels.hashes] +sha256 = "da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl" +upload-time = 2026-10-02 23:06:50+00:00 +size = 22993 + +[packages.wheels.hashes] +sha256 = "aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl" +upload-time = 2026-10-02 23:06:51+00:00 +size = 23712 + +[packages.wheels.hashes] +sha256 = "b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl" +upload-time = 2026-10-02 23:06:53+00:00 +size = 14300 + +[packages.wheels.hashes] +sha256 = "e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl" +upload-time = 2026-10-02 23:06:54+00:00 +size = 14522 + +[packages.wheels.hashes] +sha256 = "4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl" +upload-time = 2026-10-02 23:06:56+00:00 +size = 14417 + +[packages.wheels.hashes] +sha256 = "39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba" + +[[packages]] +name = "mdformat" +version = "1.0.0" +marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz" +upload-time = 2025-10-16 12:05:03+00:00 +size = 56953 + +[packages.sdist.hashes] +sha256 = "4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl" +upload-time = 2025-10-16 12:05:02+00:00 +size = 53288 + +[packages.wheels.hashes] +sha256 = "bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b" + +[[packages]] +name = "mdformat-gfm" +version = "1.0.0" +marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/56/6f/a626ebb142a290474401b67e2d61e73ce096bf7798ee22dfe6270f924b3f/mdformat_gfm-1.0.0.tar.gz" +upload-time = 2025-10-16 09:12:22+00:00 +size = 10112 + +[packages.sdist.hashes] +sha256 = "d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/e6/18/6bc2189b744dd383cad03764f41f30352b1278d2205096f77a29c0b327ad/mdformat_gfm-1.0.0-py3-none-any.whl" +upload-time = 2025-10-16 09:12:21+00:00 +size = 10970 + +[packages.wheels.hashes] +sha256 = "7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005" + +[[packages]] +name = "mdit-py-plugins" +version = "0.6.1" +marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz" +upload-time = 2026-05-13 09:03:38+00:00 +size = 56114 + +[packages.sdist.hashes] +sha256 = "a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl" +upload-time = 2026-05-13 09:03:37+00:00 +size = 66663 + +[packages.wheels.hashes] +sha256 = "214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d" + +[[packages]] +name = "mdurl" +version = "0.1.2" +marker = "\"release-attestations\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz" +upload-time = 2022-08-14 12:40:10+00:00 +size = 8729 + +[packages.sdist.hashes] +sha256 = "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl" +upload-time = 2022-08-14 12:40:09+00:00 +size = 9979 + +[packages.wheels.hashes] +sha256 = "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" + +[[packages]] +name = "more-itertools" +version = "11.2.1" +marker = "(\"test\" in dependency_groups and (platform_machine != 'ppc64le' and platform_machine != 's390x'))" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/52/b8/c63995bebbb9ac9c059350e0fb8c82c2420c85aeca7d34e40aecf3df9281/more_itertools-11.2.1.tar.gz" +upload-time = 2026-10-14 18:50:30+00:00 +size = 154133 + +[packages.sdist.hashes] +sha256 = "cbf08fd0af284dc69718b9b76a8e6203df624d70209ea511e4219d350c856f63" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/5d/57/3c4b1c6f2e0cc29ba023802458ea3d2a996f87085559e4a5f2192d549532/more_itertools-11.2.1-py3-none-any.whl" +upload-time = 2026-10-14 18:50:29+00:00 +size = 74405 + +[packages.wheels.hashes] +sha256 = "35a7377edd1dd6608dcb2cdf534ded55ea32d49448875ad042cd3f879fb1ded0" + +[[packages]] +name = "nab" +version = "0.0.22" +marker = "\"lock-tools\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/25/3d/e4c8f4b2890efbf2fa6eef82acf57ecc0ef39b0a1e6843d306d2053a107c/nab-0.0.22.tar.gz" +upload-time = 2026-10-18 05:45:08+00:00 +size = 286128 + +[packages.sdist.hashes] +sha256 = "a0da54ee96bf1eb1997581566620c250c8a3557139e9467ccaced2a12e9e71ac" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/56/4b/77baa0ddb0b7578b6475b2b578fa4579207b0f97e0b3b80480e01dcb3d6f/nab-0.0.22-py3-none-any.whl" +upload-time = 2026-10-18 05:45:06+00:00 +size = 137802 + +[packages.wheels.hashes] +sha256 = "f62fcf75b28786fbf58627f6d7eaa735daefbc7f736715403bc4357acb57e7bb" + +[[packages]] +name = "nab-index" +version = "0.0.22" +marker = "\"lock-tools\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/2a/83/8d2483239b7dc5ac8d9f758db1cc19d4129d0143d34c2e1ab8cd48ea71fb/nab_index-0.0.22.tar.gz" +upload-time = 2026-10-18 05:44:43+00:00 +size = 110621 + +[packages.sdist.hashes] +sha256 = "5900ea5ad298512379cfddff1a73a544590f0b04620ef58ff345f99a7c59688e" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/c0/58/c06ada29934a78479b989675829f26083972f38bf1e8a2503e01101cb970/nab_index-0.0.22-py3-none-any.whl" +upload-time = 2026-10-18 05:44:42+00:00 +size = 88373 + +[packages.wheels.hashes] +sha256 = "c89ffa607dfe4246dc59e9f5236d54413da3d310183ffa3298ba51a48e7c2ec7" + +[[packages]] +name = "nab-markersets" +version = "0.0.22" +marker = "\"lock-tools\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/fc/b7/3997893dfdada9998d4dc786c10445a1361e197b4aed54ddd866c84dd538/nab_markersets-0.0.22.tar.gz" +upload-time = 2026-10-18 05:44:19+00:00 +size = 109258 + +[packages.sdist.hashes] +sha256 = "38212664f517f75d798808ecfe902bfd11fef340042ab0203100c7e06e78eea5" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/5e/9e/9744adff3a66633a811f8253faaa8b91a447c99199edba16f2790e3647ea/nab_markersets-0.0.22-py3-none-any.whl" +upload-time = 2026-10-18 05:44:18+00:00 +size = 44360 + +[packages.wheels.hashes] +sha256 = "ad268dcece034495b2ea31a70250cd43cc4f6123039a548762c7ec8826e5d3bd" + +[[packages]] +name = "nab-project" +version = "0.0.22" +marker = "\"lock-tools\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/65/84/f5197d4044ceda9c6ee3e7aefce154ab616fec632fa1d89cdfaacc665c07/nab_project-0.0.22.tar.gz" +upload-time = 2026-10-18 05:44:55+00:00 +size = 774409 + +[packages.sdist.hashes] +sha256 = "5f7a1dee5b618149267b2b6233c3a220e45cd6f97bd5ec7f3f48fa7570e9f053" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/de/c9/62affa4df6cabce48751307c4c3adfbaa8c54e402d464d015897a16a6dba/nab_project-0.0.22-py3-none-any.whl" +upload-time = 2026-10-18 05:44:53+00:00 +size = 148262 + +[packages.wheels.hashes] +sha256 = "cb09fa143105fd99a60609b3ff4830924b0d4eaf5abad48678467ec4b73e577f" + +[[packages]] +name = "nab-provider" +version = "0.0.22" +marker = "\"lock-tools\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/94/0d/64fbadae562fefc00257c4f5ec988171d6e6b3ea4363f1da2e8886e80228/nab_provider-0.0.22.tar.gz" +upload-time = 2026-10-18 05:44:31+00:00 +size = 409367 + +[packages.sdist.hashes] +sha256 = "3f129ab7394bf5eb031152c3535ee74073e2a6b6ae081fb7f5a4d269fcf5f8a0" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/30/67/32bda18d34f0f8ec098f8e4a0f792164ef1a4b2aa61d435d20f4645572af/nab_provider-0.0.22-py3-none-any.whl" +upload-time = 2026-10-18 05:44:29+00:00 +size = 339242 + +[packages.wheels.hashes] +sha256 = "1450c9dc58b23da1a7bde7fdb43a72b043327bd0e3ef20435254ffcb8b7eae6b" + +[[packages]] +name = "nab-resolver" +version = "0.0.22" +marker = "\"lock-tools\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/84/bc/dcbfe5163538b1cc8421797e3f8ff77d79321cbc9943af8065e2c8ae1c09/nab_resolver-0.0.22.tar.gz" +upload-time = 2026-10-18 05:44:08+00:00 +size = 167648 + +[packages.sdist.hashes] +sha256 = "e005b225213e032858cf4aef3850961aaa1d4ea521428da731f33175ebd3b05f" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/22/a8/6e30b25ae12e0c662dc9b181a7f32fa8cd22f26016f12dc1d10176f62bba/nab_resolver-0.0.22-py3-none-any.whl" +upload-time = 2026-10-18 05:44:06+00:00 +size = 58597 + +[packages.wheels.hashes] +sha256 = "6f77daa98279ef0eedb9e8f2248aee4c50e01b92b470b68886995e5056d79971" + +[[packages]] +name = "nh3" +version = "0.3.7" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/18/2f/022b27146d52d24b1b353b003359134788ecbcd6fcdf6283adbd57c0fbc8/nh3-0.3.7.tar.gz" +upload-time = 2026-08-23 14:26:30+00:00 +size = 25662 + +[packages.sdist.hashes] +sha256 = "71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/94/0d/c257754bf57f829f307aa226bbe136d3a1356b5a0d08324c7b6bd2a8aacd/nh3-0.3.7-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl" +upload-time = 2026-08-23 14:26:09+00:00 +size = 1493959 + +[packages.wheels.hashes] +sha256 = "6c3aa50eb26e9228238271db9f983cbc3b006dfbfeca2d4dc34c33ddc6ac5ea5" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/07/42/a687e7091928806e514f89fa2666f25ec9bfe0a902fc4402b25e51ce408b/nh3-0.3.7-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" +upload-time = 2026-08-23 14:26:10+00:00 +size = 859615 + +[packages.wheels.hashes] +sha256 = "f266d3f1b3647449923a8e406524632220dd5d8b647078dfe45b885d33d10479" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/85/05/b0e6bef633549a23347d5462aa288fcc42381e7918482062ca3cb456242a/nh3-0.3.7-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl" +upload-time = 2026-08-23 14:26:12+00:00 +size = 839872 + +[packages.wheels.hashes] +sha256 = "e8fd1ab205258b29254f72db377d99e2c96aa7653ef3b015ccab0420b094b506" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/17/40/2a0921d45b20828708bcb56887e47dcf8cae13818de5bf9a01308d348712/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl" +upload-time = 2026-08-23 14:26:13+00:00 +size = 1091325 + +[packages.wheels.hashes] +sha256 = "19f288c938ec6eef1f5d2c6cab47838e71fef8097e1c1233802be5a6230ba086" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/e4/d1/9d70e0e418a48280ec0ddc6c1b08b4b1136ebcc31a1625e57ff5c665fa51/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" +upload-time = 2026-08-23 14:26:14+00:00 +size = 1042482 + +[packages.wheels.hashes] +sha256 = "de2b2aab32ea303405debefdcfc58043d3e635fa3f67b9eb140d2b0e0c0d2563" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/93/a7/02dd159d4e71f98607d8d4249cddb7561e77be1a8e4dec77d76e1b68fc99/nh3-0.3.7-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl" +upload-time = 2026-08-23 14:26:16+00:00 +size = 946868 + +[packages.wheels.hashes] +sha256 = "9b7279d43323a25225df23576af6594a16693f61431170848b8b2ac21ad4f174" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/a6/ed/c5510c615dce55b6fcc364aa1838142f938beed64f5e4927490dfcaf4405/nh3-0.3.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-08-23 14:26:17+00:00 +size = 832161 + +[packages.wheels.hashes] +sha256 = "70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/7b/e3/3212c1a5b5745245d7f18885207bbddb34c56075f34dd682bd539aad55cc/nh3-0.3.7-cp38-abi3-manylinux_2_31_riscv64.whl" +upload-time = 2026-08-23 14:26:18+00:00 +size = 849791 + +[packages.wheels.hashes] +sha256 = "5ffdfcb9a686ffb12765376bcfb6b5b55728516d3c0ee317d29982381ded3df8" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/20/64/9e36594efad6c290de4240d02cb2bd80c339a4ab1c4de66e599ffa6d9d81/nh3-0.3.7-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl" +upload-time = 2026-08-23 14:26:19+00:00 +size = 875473 + +[packages.wheels.hashes] +sha256 = "bc42bb1193c1e28a1e74c2cabaca178e118a7103e8832699fef8a2b3e2496493" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/00/0c/1a8985fd43fea5530c0ac890b6f0b423770ee72f111b70b7a77f2dec243a/nh3-0.3.7-cp38-abi3-musllinux_1_2_aarch64.whl" +upload-time = 2026-08-23 14:26:21+00:00 +size = 1036463 + +[packages.wheels.hashes] +sha256 = "d56e76bd3cadb09b6b0cef364850811663734b348a25f5f587a2819c495367bd" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b2/5d/891e533b716cf00df76ad0ba6485dcfd14d59a6430a3cc99057c4c04004e/nh3-0.3.7-cp38-abi3-musllinux_1_2_armv7l.whl" +upload-time = 2026-08-23 14:26:22+00:00 +size = 1116029 + +[packages.wheels.hashes] +sha256 = "fd4a70efb45d5372174f718878eb7a35c12677626a63b2f103b23b833457dcac" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/42/e5/ae8c0782fce74fb6fcf7234bb3d4017f37ce181b4f9d29369eab21c50a04/nh3-0.3.7-cp38-abi3-musllinux_1_2_i686.whl" +upload-time = 2026-08-23 14:26:24+00:00 +size = 1076589 + +[packages.wheels.hashes] +sha256 = "15f5fbf090f5c88d61c820e1fc1fceecb6520cca9fe85649c06b57ef9dc9ff62" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/26/a4/c3423351e8d864ad756e85e15f0c01433361f14d34e4ed156482c0518f2a/nh3-0.3.7-cp38-abi3-musllinux_1_2_x86_64.whl" +upload-time = 2026-08-23 14:26:25+00:00 +size = 1058871 + +[packages.wheels.hashes] +sha256 = "6698a822132beedab80f131c08d8d0ac5a178ddeb488d02ca4b67716ecfac7af" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/4b/6a/478f153f1d7c0baaa3d1e8bb5fdcee3a6235f90fe44ea969a9d4e2b8c47a/nh3-0.3.7-cp38-abi3-win32.whl" +upload-time = 2026-08-23 14:26:26+00:00 +size = 630729 + +[packages.wheels.hashes] +sha256 = "6e4280115d44c3b278eef712a86748c1a723105cd79feec46952383117ab4e59" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b4/b9/34433ccb1f0fe6968dabbb7d4bf5721c6221878ef07832748c06655a6a80/nh3-0.3.7-cp38-abi3-win_amd64.whl" +upload-time = 2026-08-23 14:26:28+00:00 +size = 644462 + +[packages.wheels.hashes] +sha256 = "618e3059caf41ccdf5dcccb3fa9df4cf6e4efe23d1382a8bbfca272a8a4f8bfc" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/f9/70/e140dffff6e808dc6343598df76e7e2407fd0f581de3524c75fba2e0cf24/nh3-0.3.7-cp38-abi3-win_arm64.whl" +upload-time = 2026-08-23 14:26:29+00:00 +size = 621867 + +[packages.wheels.hashes] +sha256 = "f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a" + +[[packages]] +name = "packaging" +version = "26.3" +marker = "\"lock-reader\" in dependency_groups or \"lock-tools\" in dependency_groups or \"release-attestations\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz" +upload-time = 2026-08-04 18:15:28+00:00 +size = 313412 + +[packages.sdist.hashes] +sha256 = "94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl" +upload-time = 2026-08-04 18:15:27+00:00 +size = 129956 + +[packages.wheels.hashes] +sha256 = "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" + +[[packages]] +name = "pathspec" +version = "1.1.1" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz" +upload-time = 2026-04-27 01:46:08+00:00 +size = 135180 + +[packages.sdist.hashes] +sha256 = "17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl" +upload-time = 2026-04-27 01:46:07+00:00 +size = 57328 + +[packages.wheels.hashes] +sha256 = "a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189" + +[[packages]] +name = "platformdirs" +version = "4.13.4" +marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/b7/89/dfd269de936e76319cf27787d9eaaca513491f635d49b0fbe7cdb6d3bf6f/platformdirs-4.13.4.tar.gz" +upload-time = 2026-10-17 19:31:53+00:00 +size = 62364 + +[packages.sdist.hashes] +sha256 = "dd96496961684a7f42041923f842ec7494e09f5e87aded99117e1109cd40570e" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/58/51/5614d0537dd830ca64d95a2f254bd66bdf3ac61d41cffcccaec62121fcfd/platformdirs-4.13.4-py3-none-any.whl" +upload-time = 2026-10-17 19:31:52+00:00 +size = 32782 + +[packages.wheels.hashes] +sha256 = "502ce08e9d36cf90579ee44e1e764838842fa658e4975ef47344fd053011d2c8" + +[[packages]] +name = "pluggy" +version = "1.7.0" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz" +upload-time = 2026-10-15 09:50:58+00:00 +size = 123304 + +[packages.sdist.hashes] +sha256 = "d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl" +upload-time = 2026-10-15 09:50:56+00:00 +size = 27082 + +[packages.wheels.hashes] +sha256 = "7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec" + +[[packages]] +name = "prek" +version = "0.5.5" +marker = "\"release-check\" in dependency_groups or \"release-hook-check\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-shell\" in dependency_groups or \"release-validate\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/9b/7b/11538b8e6cd7e269261feae62a2d08818fdcf8f43c3b1a253cec01a5488d/prek-0.5.5.tar.gz" +upload-time = 2026-10-05 06:08:59+00:00 +size = 570428 + +[packages.sdist.hashes] +sha256 = "16bf596768bb754779c7d42317e9ca16ac609c0cce7d13bae0829944ab40a26a" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/2e/77/189e554ea92baf6c1d33733b3e9d1ed819ab7d77e57eb2e3ead0bb1d8060/prek-0.5.5-py3-none-macosx_10_12_x86_64.whl" +upload-time = 2026-10-05 06:08:39+00:00 +size = 6038669 + +[packages.wheels.hashes] +sha256 = "50fbceb0f728b504023f6e6c9037ac0656436949d94f7a85bac5542e6c1a4a75" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/63/ea/fc857c6b3f1e3fdb4d81bdca2051076de82e986cb1455a01b351ac7a5cd1/prek-0.5.5-py3-none-macosx_11_0_arm64.whl" +upload-time = 2026-10-05 06:08:41+00:00 +size = 5548955 + +[packages.wheels.hashes] +sha256 = "2f610927b95e84b28173a4c810892a7b5d5bb55bee792ddf97178fa97654633e" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/47/c3/2bab54f6393fb9f247225681d476086e24fa6242f329e9ca1e5bd638ec50/prek-0.5.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl" +upload-time = 2026-10-05 06:08:44+00:00 +size = 5848228 + +[packages.wheels.hashes] +sha256 = "120ef7d4b6bb58155e74827743c266e48501efb0e4c37ff726b14fe6e86a126b" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/aa/f9/1279e1c83819de5904db1a09f4afc589932a2e62c7063713b1fe3a54ee46/prek-0.5.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" +upload-time = 2026-10-05 06:08:46+00:00 +size = 6371296 + +[packages.wheels.hashes] +sha256 = "2f6c453a12c21757b23bbbc97415aef78527841ad3f9e61f52cb35cf012f93e5" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/93/fc/e566be5919eaffef4cc22488f16e4bbfd08961734bd67c06f85d405e4cbc/prek-0.5.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-10-05 06:08:48+00:00 +size = 6269922 + +[packages.wheels.hashes] +sha256 = "73d509c0d9556171de2502bada4962be7a25871e7b23e782549074e1416dc850" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d2/39/a48d8e6203055bff7bb734b1f2f9aede4a4539bee6ab4c9273dbc01fa546/prek-0.5.5-py3-none-manylinux_2_28_aarch64.whl" +upload-time = 2026-10-05 06:08:50+00:00 +size = 5861518 + +[packages.wheels.hashes] +sha256 = "76c867fdbd4e4f19e6b6b2988bd1e1407d1324170ebcf1428e4e34f8f0751a99" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b7/03/690fba9543033e25ea2e7b2ae1cca1ae8d8c2e32295f0d66f4b50934b8c7/prek-0.5.5-py3-none-musllinux_1_1_x86_64.whl" +upload-time = 2026-10-05 06:08:52+00:00 +size = 6383685 + +[packages.wheels.hashes] +sha256 = "c69f60c2b3b4d481e38d0eab3b97ac05ec05d7648ae690925ae95ca9d22c7643" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/8c/04/1e9ccf3a61a3bfd4107b83973f7afde9177b6ff8f2d5d4b00125e77c2100/prek-0.5.5-py3-none-win_amd64.whl" +upload-time = 2026-10-05 06:08:55+00:00 +size = 5784759 + +[packages.wheels.hashes] +sha256 = "15bff9039349466ea9bf6b0c173b822ff08f8ae4cfbfdea782492e519c06d874" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/41/d9/bb6811e438d3c74d26cb5d72e6343606e0a7a6283fee87521189f4ba1191/prek-0.5.5-py3-none-win_arm64.whl" +upload-time = 2026-10-05 06:08:57+00:00 +size = 5517355 + +[packages.wheels.hashes] +sha256 = "3db3191d2509830c75438e20956a47c2259867d18a4106064ed75088b968a0c6" + +[[packages]] +name = "pyasn1" +version = "0.6.4" +marker = "\"release-attestations\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz" +upload-time = 2026-07-09 01:12:33+00:00 +size = 151262 + +[packages.sdist.hashes] +sha256 = "9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl" +upload-time = 2026-07-09 01:12:32+00:00 +size = 84410 + +[packages.wheels.hashes] +sha256 = "deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b" + +[[packages]] +name = "pycparser" +version = "3.11" +marker = "(\"release-attestations\" in dependency_groups and (implementation_name != 'PyPy' and platform_python_implementation != 'PyPy')) or (\"test\" in dependency_groups and (implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'))" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz" +upload-time = 2026-10-09 12:56:59+00:00 +size = 113796 + +[packages.sdist.hashes] +sha256 = "d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl" +upload-time = 2026-10-09 12:56:58+00:00 +size = 51178 + +[packages.wheels.hashes] +sha256 = "51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80" + +[[packages]] +name = "pydantic" +version = "2.14.1" +marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz" +upload-time = 2026-10-11 18:37:55+00:00 +size = 900362 + +[packages.sdist.hashes] +sha256 = "94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl" +upload-time = 2026-10-11 18:37:53+00:00 +size = 488106 + +[packages.wheels.hashes] +sha256 = "9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454" + +[[packages]] +name = "pydantic-core" +version = "2.50.1" +marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz" +upload-time = 2026-10-11 18:35:44+00:00 +size = 513515 + +[packages.sdist.hashes] +sha256 = "e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl" +upload-time = 2026-10-11 18:34:04+00:00 +size = 2224599 + +[packages.wheels.hashes] +sha256 = "8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl" +upload-time = 2026-10-11 18:34:05+00:00 +size = 2024191 + +[packages.wheels.hashes] +sha256 = "84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" +upload-time = 2026-10-11 18:34:07+00:00 +size = 2048252 + +[packages.wheels.hashes] +sha256 = "23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl" +upload-time = 2026-10-11 18:34:09+00:00 +size = 2173376 + +[packages.wheels.hashes] +sha256 = "3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" +upload-time = 2026-10-11 18:34:11+00:00 +size = 2364559 + +[packages.wheels.hashes] +sha256 = "c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl" +upload-time = 2026-10-11 18:34:13+00:00 +size = 2444697 + +[packages.wheels.hashes] +sha256 = "ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831" + +[[packages.wheels]] +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" +upload-time = 2026-10-11 18:34:15+00:00 +size = 2159961 + +[packages.wheels.hashes] +sha256 = "7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl" +upload-time = 2026-10-11 18:34:17+00:00 +size = 2234581 + +[packages.wheels.hashes] +sha256 = "d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl" +upload-time = 2026-10-11 18:34:19+00:00 +size = 2244090 + +[packages.wheels.hashes] +sha256 = "40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl" +upload-time = 2026-10-11 18:34:21+00:00 +size = 2277948 + +[packages.wheels.hashes] +sha256 = "d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl" +upload-time = 2026-10-11 18:34:23+00:00 +size = 2449688 + +[packages.wheels.hashes] +sha256 = "84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl" +upload-time = 2026-10-11 18:34:25+00:00 +size = 2479286 + +[packages.wheels.hashes] +sha256 = "ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl" +upload-time = 2026-10-11 18:34:27+00:00 +size = 2103683 + +[packages.wheels.hashes] +sha256 = "bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl" +upload-time = 2026-10-11 18:34:29+00:00 +size = 2171681 + +[packages.wheels.hashes] +sha256 = "983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl" +upload-time = 2026-10-11 18:34:31+00:00 +size = 2156251 + +[packages.wheels.hashes] +sha256 = "94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl" +upload-time = 2026-10-11 18:34:33+00:00 +size = 2234363 + +[packages.wheels.hashes] +sha256 = "4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl" +upload-time = 2026-10-11 18:34:36+00:00 +size = 2000972 + +[packages.wheels.hashes] +sha256 = "efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" +upload-time = 2026-10-11 18:34:37+00:00 +size = 2022925 + +[packages.wheels.hashes] +sha256 = "bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl" +upload-time = 2026-10-11 18:34:39+00:00 +size = 2192867 + +[packages.wheels.hashes] +sha256 = "5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" +upload-time = 2026-10-11 18:34:42+00:00 +size = 2388687 + +[packages.wheels.hashes] +sha256 = "adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl" +upload-time = 2026-10-11 18:34:44+00:00 +size = 2464415 + +[packages.wheels.hashes] +sha256 = "2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a" + +[[packages.wheels]] +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" +upload-time = 2026-10-11 18:34:46+00:00 +size = 2150823 + +[packages.wheels.hashes] +sha256 = "23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl" +upload-time = 2026-10-11 18:34:48+00:00 +size = 2245806 + +[packages.wheels.hashes] +sha256 = "f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl" +upload-time = 2026-10-11 18:34:50+00:00 +size = 2264160 + +[packages.wheels.hashes] +sha256 = "455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl" +upload-time = 2026-10-11 18:34:52+00:00 +size = 2294031 + +[packages.wheels.hashes] +sha256 = "1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl" +upload-time = 2026-10-11 18:34:54+00:00 +size = 2468423 + +[packages.wheels.hashes] +sha256 = "d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl" +upload-time = 2026-10-11 18:34:57+00:00 +size = 2512598 + +[packages.wheels.hashes] +sha256 = "009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl" +upload-time = 2026-10-11 18:34:59+00:00 +size = 2098535 + +[packages.wheels.hashes] +sha256 = "3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl" +upload-time = 2026-10-11 18:35:01+00:00 +size = 2148645 + +[packages.wheels.hashes] +sha256 = "1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl" +upload-time = 2026-10-11 18:35:04+00:00 +size = 2175297 + +[packages.wheels.hashes] +sha256 = "c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44" + +[[packages]] +name = "pygments" +version = "2.21.0" +marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz" +upload-time = 2026-08-17 08:02:48+00:00 +size = 5005329 + +[packages.sdist.hashes] +sha256 = "610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl" +upload-time = 2026-08-17 08:02:44+00:00 +size = 1250147 + +[packages.wheels.hashes] +sha256 = "2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9" + +[[packages]] +name = "pyjwt" +version = "2.15.1" +marker = "\"release-attestations\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz" +upload-time = 2026-09-28 18:40:42+00:00 +size = 121252 + +[packages.sdist.hashes] +sha256 = "4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl" +upload-time = 2026-09-28 18:40:41+00:00 +size = 33860 + +[packages.wheels.hashes] +sha256 = "42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193" + +[[packages]] +name = "pyopenssl" +version = "26.4.0" +marker = "\"release-attestations\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz" +upload-time = 2026-08-01 19:50:50+00:00 +size = 182046 + +[packages.sdist.hashes] +sha256 = "28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl" +upload-time = 2026-08-01 19:50:48+00:00 +size = 56026 + +[packages.wheels.hashes] +sha256 = "f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c" + +[[packages]] +name = "pypi-attestations" +version = "0.0.30" +marker = "\"release-attestations\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/97/1e/eb8a0233cdcaf01acb18076ac8b8a057714177b3717baf26b78a11b8d929/pypi_attestations-0.0.30.tar.gz" +upload-time = 2026-07-28 14:08:35+00:00 +size = 139108 + +[packages.sdist.hashes] +sha256 = "14ff13c76bbef02a483c2b77532da02bfd746b5141b881f355e06b9807434423" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/5f/24/e59078318b5e2bca59be5a21957d70e082e6eb4f478adffe373f3b74daf4/pypi_attestations-0.0.30-py3-none-any.whl" +upload-time = 2026-07-28 14:08:33+00:00 +size = 23015 + +[packages.wheels.hashes] +sha256 = "b3a9c53f6cb89e5e7b5b70e6cfca97cfc66008c1ed54087355e06e40071cef21" + +[[packages]] +name = "pyproject-api" +version = "1.12.0" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/9a/c6/04bb77bccfa36aabc783a31e335b5441829f5ec9ff16e313b5b7022d81a8/pyproject_api-1.12.0.tar.gz" +upload-time = 2026-10-18 07:00:59+00:00 +size = 28058 + +[packages.sdist.hashes] +sha256 = "2e83226079dc446a8e2357def7622c01c733284e39afc8e332d9f722fe0c2beb" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b9/b6/ce66a85089985da9b15b0e0e0d668c4b26c994f619e28a11d609454f3d70/pyproject_api-1.12.0-py3-none-any.whl" +upload-time = 2026-10-18 07:00:58+00:00 +size = 14055 + +[packages.wheels.hashes] +sha256 = "1d3cbf37ac4c3a40940e09cbd4374608d25cdd89e6019f7e06fc285afd3ab467" + +[[packages]] +name = "pyproject-hooks" +version = "1.3.3" +marker = "\"lock-tools\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/6d/5d/f2ddeef4a855a102aaae5e97826a0260007522ab504421b75addfdb1517c/pyproject_hooks-1.3.3.tar.gz" +upload-time = 2026-09-16 08:58:03+00:00 +size = 21013 + +[packages.sdist.hashes] +sha256 = "defda19b854fa0d3bd4f76ea4ddcba8abd7dcfcdd585a6690ade050744fc5f43" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/85/11/044d1ae1b4ec0d7af88ee5bc91e081be1022533032b906a7bdabdbb60977/pyproject_hooks-1.3.3-py3-none-any.whl" +upload-time = 2026-09-16 08:58:02+00:00 +size = 10724 + +[packages.wheels.hashes] +sha256 = "5fc53fdac9f7bd63fbcdc868fb5f90b4784d78a53a3d3388cd738b807441a20b" + +[[packages]] +name = "pytest" +version = "9.1.1" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz" +upload-time = 2026-06-19 10:58:32+00:00 +size = 1636369 + +[packages.sdist.hashes] +sha256 = "1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl" +upload-time = 2026-06-19 10:58:31+00:00 +size = 386536 + +[packages.wheels.hashes] +sha256 = "37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" + +[[packages]] +name = "pytest-httpserver" +version = "1.2.0" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/37/ff/81c955e8fbb0323e817d178dd653de90c81896e819a5df65c1e1c327155a/pytest_httpserver-1.2.0.tar.gz" +upload-time = 2026-10-05 19:00:25+00:00 +size = 71860 + +[packages.sdist.hashes] +sha256 = "d07744ce2efaafa0575ed3081d416b999c773b09e84aa95065138bedc3e12352" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/8d/34/aa11718873f8cd3d9b3c58938e537af0c2d9cb108160357f5db2af6ddd66/pytest_httpserver-1.2.0-py3-none-any.whl" +upload-time = 2026-10-05 19:00:26+00:00 +size = 23392 + +[packages.wheels.hashes] +sha256 = "749937892dbf909c8b703503b491be66fc24073e7988bf9b4d783af4f33da942" + +[[packages]] +name = "pytest-mock" +version = "3.17.0" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/97/c2/28b9f5b955ff2bcd4727a6f768e2ca9b586cd7c37b95ea5f7b9b873ac7b3/pytest_mock-3.17.0.tar.gz" +upload-time = 2026-10-17 16:21:42+00:00 +size = 43064 + +[packages.sdist.hashes] +sha256 = "e36576e0985e01027e999891e7a966c67822d40c89ef1b4c8b6e22737e3590b4" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/e8/ea/47fbdc8f37410956fd445720e927589b1e2a47772c5f28109f0ce2fa26d9/pytest_mock-3.17.0-py3-none-any.whl" +upload-time = 2026-10-17 16:21:41+00:00 +size = 11248 + +[packages.wheels.hashes] +sha256 = "1b82aa4b37137248bd687055e109723debeb07b489de1565297dc9589c5f2d6b" + +[[packages]] +name = "pytest-xdist" +version = "3.8.0" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz" +upload-time = 2025-07-01 13:30:59+00:00 +size = 88069 + +[packages.sdist.hashes] +sha256 = "7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl" +upload-time = 2025-07-01 13:30:56+00:00 +size = 46396 + +[packages.wheels.hashes] +sha256 = "202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88" + +[[packages]] +name = "python-discovery" +version = "1.6.3" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/fc/54/fad125f5c48f7821644ee6f223ebfcedc4e23597c1a16eeb13d33aa4a91a/python_discovery-1.6.3.tar.gz" +upload-time = 2026-10-16 03:54:29+00:00 +size = 88998 + +[packages.sdist.hashes] +sha256 = "a62b301d96cf5489cb96ab023b32e068421867fdd5df2e42eece877869047fa2" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/de/ec/643826e4d69b7bdce4213f221e8f5ed06d0a9dd69fa9b33342578d2cc8c8/python_discovery-1.6.3-py3-none-any.whl" +upload-time = 2026-10-16 03:54:28+00:00 +size = 39386 + +[packages.wheels.hashes] +sha256 = "cb7654125e3dcb594269a6feb56ac693f1f13b7c654493a93eba72dc22d18034" + +[[packages]] +name = "pywin32-ctypes" +version = "0.2.3" +marker = "(\"test\" in dependency_groups and (platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'win32'))" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz" +upload-time = 2024-08-14 10:15:34+00:00 +size = 29471 + +[packages.sdist.hashes] +sha256 = "d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl" +upload-time = 2024-08-14 10:15:33+00:00 +size = 30756 + +[packages.wheels.hashes] +sha256 = "8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8" + +[[packages]] +name = "pyyaml" +version = "6.0.3" +marker = "\"release-hook-check\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-mise-editor\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-shell\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz" +upload-time = 2025-09-25 21:33:16+00:00 +size = 130960 + +[packages.sdist.hashes] +sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" + +[[packages]] +name = "readme-renderer" +version = "46.0" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/25/d7/9309494fad74ee831d4546f69325b5519f37c6dfb2d9ba495db8c6d4f4ca/readme_renderer-46.0.tar.gz" +upload-time = 2026-08-28 15:18:32+00:00 +size = 38382 + +[packages.sdist.hashes] +sha256 = "af3e964914f6310a33ff67b72a4bdd940bed8d7c3bdecd2d14f40edf284bfe90" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/4b/72/ac5ca81fe9121fcaa9d828d21017cba00a16a98e4ea5fb60c878f93dda4f/readme_renderer-46.0-py3-none-any.whl" +upload-time = 2026-08-28 15:18:31+00:00 +size = 14239 + +[packages.wheels.hashes] +sha256 = "d0dae1f74bb273b534770cb4cccb6bb78735540afdb03c2146f4e19dcd412560" + +[[packages]] +name = "release-creator" +version = "2.0.0" +marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/5d/40/55f7a22a3cda929f9efdf09dbb825e888487e658ce14892f2340218335c1/release_creator-2.0.0.tar.gz" +upload-time = 2026-10-25 07:08:31+00:00 +size = 209257 + +[packages.sdist.hashes] +sha256 = "f7ccec9787327a7e12f4a79c15dff470e894d3a88c9bb60eac9c6f1984ee63bf" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/88/f1/d7103a5c410019207015fc8229f976e8906da90409eddd5e00066bb360ca/release_creator-2.0.0-py3-none-any.whl" +upload-time = 2026-10-25 07:08:29+00:00 +size = 66805 + +[packages.wheels.hashes] +sha256 = "cf93c2395f4a9ba80bd503792c16189fc238c5d82bb90535250de2968d8d2e92" + +[[packages]] +name = "requests" +version = "2.34.2" +marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz" +upload-time = 2026-05-14 19:25:27+00:00 +size = 142856 + +[packages.sdist.hashes] +sha256 = "f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl" +upload-time = 2026-05-14 19:25:26+00:00 +size = 73075 + +[packages.wheels.hashes] +sha256 = "2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" + +[[packages]] +name = "requests-toolbelt" +version = "1.0.0" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz" +upload-time = 2023-05-01 04:11:33+00:00 +size = 206888 + +[packages.sdist.hashes] +sha256 = "7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl" +upload-time = 2023-05-01 04:11:28+00:00 +size = 54481 + +[packages.wheels.hashes] +sha256 = "cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" + +[[packages]] +name = "rfc3161-client" +version = "1.0.9" +marker = "\"release-attestations\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/56/53/e2f526fb6957023180c928448e2dd71b03b35038c315aed2d12d9a77c3af/rfc3161_client-1.0.9.tar.gz" +upload-time = 2026-09-14 19:32:29+00:00 +size = 112825 + +[packages.sdist.hashes] +sha256 = "66997415086d2c6e5d57a2d315157b32a9c3f4f1312877ca8eb55e2e0ddf8ce4" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/92/2c/b81b822bc067647aa291dc4ea71f052f994344934fc84d499759aec64cf8/rfc3161_client-1.0.9-cp39-abi3-macosx_10_12_x86_64.whl" +upload-time = 2026-09-14 19:32:08+00:00 +size = 2109721 + +[packages.wheels.hashes] +sha256 = "5e619d7e28ffa3a46685923e6d6ba4ce2e74cb09a41c77995a0702cc1a533bc4" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/88/cf/bcfb66eaeccdc076457451a6213360c840bcacd0e938e8507951be29373f/rfc3161_client-1.0.9-cp39-abi3-macosx_11_0_arm64.whl" +upload-time = 2026-09-14 19:32:10+00:00 +size = 2439706 + +[packages.wheels.hashes] +sha256 = "a7004edfbf1bb7ec7801978a39fa7a7fe385ca59673df90d7578079b6eba3b24" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/8e/f3/4b69d47fd7f79238bf8db20902a30d28c908efc0d906fb57abcc89ac75e3/rfc3161_client-1.0.9-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" +upload-time = 2026-09-14 19:32:12+00:00 +size = 2651455 + +[packages.wheels.hashes] +sha256 = "7b703b233dbee7228d117d5f1c118363c61d523fa6f018c9217c79e3c6684f86" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/33/49/154c3e80ac94e51b9d1097dab15b95f44aa593c395bd41775e4486a7d0a0/rfc3161_client-1.0.9-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl" +upload-time = 2026-09-14 19:32:13+00:00 +size = 2051342 + +[packages.wheels.hashes] +sha256 = "d3cbf1991b6f3457efcb25e1d31179cac556112e6d3e65ec9e0fd70c6e9af90d" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/c8/57/79bf6a67cd4ca729b0b74e6fa79ebef84e6bc8657b85772df01a7d3b7b89/rfc3161_client-1.0.9-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl" +upload-time = 2026-09-14 19:32:15+00:00 +size = 2418228 + +[packages.wheels.hashes] +sha256 = "614da494c6f8850c5d6d98c23bc291a4e4dbf2c3c9082d9907fa156f9e4a75c6" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/11/ae/19139fee184916065705d660bad203b676dc40b449afff36f79818c59c4f/rfc3161_client-1.0.9-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-09-14 19:32:17+00:00 +size = 2415566 + +[packages.wheels.hashes] +sha256 = "fac3f440a507555e684dc5daba75e33dc08f0f45fdefa48448f19001233a6b21" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/6e/54/43f89c8e004baade51943261b29f9dd866df3383f4554d5797a2568c2855/rfc3161_client-1.0.9-cp39-abi3-musllinux_1_2_aarch64.whl" +upload-time = 2026-09-14 19:32:19+00:00 +size = 2995975 + +[packages.wheels.hashes] +sha256 = "0ac247d5aa22f3514d1e284bdc547d2ad5f101e1e5d7f6db25db2ab3a0c59f06" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/2b/b2/938e4df767a2ea6fae2bc8e8e0df52e1d70a32a082a13bdfdd6fac59dc32/rfc3161_client-1.0.9-cp39-abi3-musllinux_1_2_armv7l.whl" +upload-time = 2026-09-14 19:32:20+00:00 +size = 2358728 + +[packages.wheels.hashes] +sha256 = "03be7ac7919c7f4c566076b07773b81b1f1b28d447fa91c6960fb5f82fac5bc1" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/ff/54/f0193b27f1a635aa336d19b48c296f133719cafb87e950de6eae4638a046/rfc3161_client-1.0.9-cp39-abi3-musllinux_1_2_i686.whl" +upload-time = 2026-09-14 19:32:22+00:00 +size = 2565766 + +[packages.wheels.hashes] +sha256 = "8208999e94e1252e3f5f748f5efe59d3b9c308d04c7626be1becfbf3236c9de4" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/15/41/6c48b05fc41a424ab3b0cb0515e77046707b498837411633fe7ce77812ee/rfc3161_client-1.0.9-cp39-abi3-musllinux_1_2_x86_64.whl" +upload-time = 2026-09-14 19:32:24+00:00 +size = 2643597 + +[packages.wheels.hashes] +sha256 = "d4cf92f0e784ff02ff5ea1c574f8521f30953dc178092424126dbd8348034f93" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d2/18/fcb2f311b0b1754b0476165a4eae2929606007fc0ff0a575a65dbff2c0f6/rfc3161_client-1.0.9-cp39-abi3-win32.whl" +upload-time = 2026-09-14 19:32:25+00:00 +size = 1996018 + +[packages.wheels.hashes] +sha256 = "4d33f5aaec2974239e1c62a79a6fcf32506b09095482377a8b5be3a00fa67dfb" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d2/a1/b71d9f2071660a914e039061d3cdcad77b1cbccf7276bff073f95e8b4d1a/rfc3161_client-1.0.9-cp39-abi3-win_amd64.whl" +upload-time = 2026-09-14 19:32:27+00:00 +size = 2431351 + +[packages.wheels.hashes] +sha256 = "908f6a775da4bdce1d39f825a4431f389542696078ef3e4ed911a068bcaf1792" + +[[packages]] +name = "rfc3986" +version = "2.0.0" +marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz" +upload-time = 2022-01-10 00:52:30+00:00 +size = 49026 + +[packages.sdist.hashes] +sha256 = "97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl" +upload-time = 2022-01-10 00:52:29+00:00 +size = 31326 + +[packages.wheels.hashes] +sha256 = "50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd" + +[[packages]] +name = "rfc8785" +version = "0.1.4" +marker = "\"release-attestations\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/ef/2f/fa1d2e740c490191b572d33dbca5daa180cb423c24396b856f5886371d8b/rfc8785-0.1.4.tar.gz" +upload-time = 2024-09-27 16:33:31+00:00 +size = 14321 + +[packages.sdist.hashes] +sha256 = "e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/4d/78/119878110660b2ad709888c8a1614fce7e2fab39080ab960656dc8605bf6/rfc8785-0.1.4-py3-none-any.whl" +upload-time = 2024-09-27 16:33:29+00:00 +size = 9240 + +[packages.wheels.hashes] +sha256 = "520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48" + +[[packages]] +name = "rich" +version = "15.0.0" +marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz" +upload-time = 2026-04-12 08:24:00+00:00 +size = 230680 + +[packages.sdist.hashes] +sha256 = "edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl" +upload-time = 2026-04-12 08:24:02+00:00 +size = 310654 + +[packages.wheels.hashes] +sha256 = "33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" + +[[packages]] +name = "secretstorage" +version = "3.5.0" +marker = "(\"test\" in dependency_groups and (platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'))" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz" +upload-time = 2025-11-23 19:02:53+00:00 +size = 19884 + +[packages.sdist.hashes] +sha256 = "f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl" +upload-time = 2025-11-23 19:02:51+00:00 +size = 15554 + +[packages.wheels.hashes] +sha256 = "0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137" + +[[packages]] +name = "securesystemslib" +version = "1.5.1" +marker = "\"release-attestations\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/81/d4/f9323507144693d869f30f13694035438ab6642153b94e3057945cf0584f/securesystemslib-1.5.1.tar.gz" +upload-time = 2026-09-04 06:55:32+00:00 +size = 947087 + +[packages.sdist.hashes] +sha256 = "4b8d00abd93707ead10b69eb2b8582376a1364de3b0a71077de534c2ef4985e0" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/38/fc/c71335a5046d46bf3b761a8c7fa092b40a7721429a5613538f0a28ae80a6/securesystemslib-1.5.1-py3-none-any.whl" +upload-time = 2026-09-04 06:55:30+00:00 +size = 876390 + +[packages.wheels.hashes] +sha256 = "ada8bdf817da29ece4ba91654f6a162ce7cfbadbc3ae3f840f7313f9d22675de" + +[[packages]] +name = "setuptools" +version = "84.0.0" +marker = "\"build\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz" +upload-time = 2026-08-08 18:27:58+00:00 +size = 1168449 + +[packages.sdist.hashes] +sha256 = "f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl" +upload-time = 2026-08-08 18:27:56+00:00 +size = 818216 + +[packages.wheels.hashes] +sha256 = "51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670" + +[[packages]] +name = "sigstore" +version = "4.5.0" +marker = "\"release-attestations\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/18/e0/279419065e2d7102413605b3456122adbbccbc42e010b499c7b882fc01f8/sigstore-4.5.0.tar.gz" +upload-time = 2026-07-28 07:34:01+00:00 +size = 90969 + +[packages.sdist.hashes] +sha256 = "020d3e07f622b2916bf453e66ff6ff0711e1fdc5ab69e8bd8902f71d9fcb316f" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/bf/7f/51dc313c06dd3ec6c5b308e334492cfdea0b02c9184a0da7b2db8c2a30a1/sigstore-4.5.0-py3-none-any.whl" +upload-time = 2026-07-28 07:34:00+00:00 +size = 111724 + +[packages.wheels.hashes] +sha256 = "f045b207f2e12605cf775ec38e89c5eda625d71ffa7830477db65e47ec2bc8b2" + +[[packages]] +name = "sigstore-models" +version = "0.0.6" +marker = "\"release-attestations\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/c6/ed/5c0ff809f90b19f4e971e17c1ed11f4df60082c6010b32a82054087e91e0/sigstore_models-0.0.6.tar.gz" +upload-time = 2025-11-26 19:18:12+00:00 +size = 7037 + +[packages.sdist.hashes] +sha256 = "c766c09470c2a7e8a4a333c893f07e2001c56a3ff1757b1a246119f53169a849" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/12/dc/7a19864a7bc9f43121ab459e12768ab0080590e3e1b60a29b2f2eb01fb85/sigstore_models-0.0.6-py3-none-any.whl" +upload-time = 2025-11-26 19:18:11+00:00 +size = 13213 + +[packages.wheels.hashes] +sha256 = "5201a68f4d7d0f8bec1e2f4378eb646b084c52609a4e31db8c385095fff68b2e" + +[[packages]] +name = "sigstore-rekor-types" +version = "0.0.18" +marker = "\"release-attestations\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/b4/54/102e772445c5e849b826fbdcd44eb9ad7b3d10fda17b08964658ec7027dc/sigstore_rekor_types-0.0.18.tar.gz" +upload-time = 2024-11-22 13:59:54+00:00 +size = 15687 + +[packages.sdist.hashes] +sha256 = "19aef25433218ebf9975a1e8b523cc84aaf3cd395ad39a30523b083ea7917ec5" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/45/7c/f0b4e19fd424df4cc964f5d454e1d814fd2dc3b386342e6040441024318f/sigstore_rekor_types-0.0.18-py3-none-any.whl" +upload-time = 2024-11-22 13:59:52+00:00 +size = 20610 + +[packages.wheels.hashes] +sha256 = "b62bf38c5b1a62bc0d7fe0ee51a0709e49311d137c7880c329882a8f4b2d1d78" + +[[packages]] +name = "tomli" +version = "2.5.0" +marker = "\"lock-tools\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz" +upload-time = 2026-10-07 12:23:37+00:00 +size = 17662 + +[packages.sdist.hashes] +sha256 = "264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl" +upload-time = 2026-10-07 12:23:13+00:00 +size = 162775 + +[packages.wheels.hashes] +sha256 = "a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl" +upload-time = 2026-10-07 12:23:15+00:00 +size = 161406 + +[packages.wheels.hashes] +sha256 = "9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276" + +[[packages.wheels]] +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" +upload-time = 2026-10-07 12:23:16+00:00 +size = 273855 + +[packages.wheels.hashes] +sha256 = "752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52" + +[[packages.wheels]] +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" +upload-time = 2026-10-07 12:23:18+00:00 +size = 284910 + +[packages.wheels.hashes] +sha256 = "c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl" +upload-time = 2026-10-07 12:23:19+00:00 +size = 277723 + +[packages.wheels.hashes] +sha256 = "19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl" +upload-time = 2026-10-07 12:23:20+00:00 +size = 285115 + +[packages.wheels.hashes] +sha256 = "57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl" +upload-time = 2026-10-07 12:23:21+00:00 +size = 103475 + +[packages.wheels.hashes] +sha256 = "667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl" +upload-time = 2026-10-07 12:23:23+00:00 +size = 114589 + +[packages.wheels.hashes] +sha256 = "d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl" +upload-time = 2026-10-07 12:23:24+00:00 +size = 111493 + +[packages.wheels.hashes] +sha256 = "75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl" +upload-time = 2026-10-07 12:23:25+00:00 +size = 171380 + +[packages.wheels.hashes] +sha256 = "2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl" +upload-time = 2026-10-07 12:23:26+00:00 +size = 170553 + +[packages.wheels.hashes] +sha256 = "0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd" + +[[packages.wheels]] +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" +upload-time = 2026-10-07 12:23:28+00:00 +size = 294428 + +[packages.wheels.hashes] +sha256 = "49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01" + +[[packages.wheels]] +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" +upload-time = 2026-10-07 12:23:29+00:00 +size = 304909 + +[packages.wheels.hashes] +sha256 = "b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl" +upload-time = 2026-10-07 12:23:30+00:00 +size = 293220 + +[packages.wheels.hashes] +sha256 = "b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl" +upload-time = 2026-10-07 12:23:32+00:00 +size = 305705 + +[packages.wheels.hashes] +sha256 = "8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl" +upload-time = 2026-10-07 12:23:33+00:00 +size = 108432 + +[packages.wheels.hashes] +sha256 = "62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl" +upload-time = 2026-10-07 12:23:34+00:00 +size = 117281 + +[packages.wheels.hashes] +sha256 = "f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl" +upload-time = 2026-10-07 12:23:35+00:00 +size = 115069 + +[packages.wheels.hashes] +sha256 = "d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl" +upload-time = 2026-10-07 12:23:36+00:00 +size = 14765 + +[packages.wheels.hashes] +sha256 = "32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b" + +[[packages]] +name = "tomli-w" +version = "1.2.0" +marker = "\"lock-tools\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz" +upload-time = 2025-01-15 12:07:24+00:00 +size = 7184 + +[packages.sdist.hashes] +sha256 = "2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl" +upload-time = 2025-01-15 12:07:22+00:00 +size = 6675 + +[packages.wheels.hashes] +sha256 = "188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90" + +[[packages]] +name = "tomlkit" +version = "0.15.2" +marker = "\"lock-tools\" in dependency_groups or \"release-hook-check\" in dependency_groups or \"release-shell\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/08/72/fc01563df95c189a9f65850096bdc205bc2fe37da6c740cd64afaf18d07e/tomlkit-0.15.2.tar.gz" +upload-time = 2026-10-16 04:07:08+00:00 +size = 190522 + +[packages.sdist.hashes] +sha256 = "83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/da/46/7d84138a60a8bd796825b2d38ebe92d0d16501ca57d501b7c5d677401bbc/tomlkit-0.15.2-py3-none-any.whl" +upload-time = 2026-10-16 04:07:09+00:00 +size = 52225 + +[packages.wheels.hashes] +sha256 = "05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437" + +[[packages]] +name = "towncrier" +version = "26.9.0" +marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/92/d4/e85ce614bf45b1a7fe13b82cc687819c2eaac5a1dd94b84834322095fd98/towncrier-26.9.0.tar.gz" +upload-time = 2026-09-04 12:57:25+00:00 +size = 79338 + +[packages.sdist.hashes] +sha256 = "ace9031631c718cc0709107b8755e6e31350eb8acdc877af43df3177addb9a55" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/82/88/f9340b545dafa64de053798e760515a2736fbb60092cf9aa6b146ccf3ede/towncrier-26.9.0-py3-none-any.whl" +upload-time = 2026-09-04 12:57:23+00:00 +size = 67030 + +[packages.wheels.hashes] +sha256 = "ae4d223e6aadaff98d29b7f09e58d9c9ccf4cd3b455a2d2e0486d432cd8bf660" + +[[packages]] +name = "tox" +version = "4.67.0" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/4a/79/ce1ce8c98a1ff8ff318191ed1b4f113a3a8626fccfcedc687f7a485b5d7f/tox-4.67.0.tar.gz" +upload-time = 2026-10-17 19:15:47+00:00 +size = 346271 + +[packages.sdist.hashes] +sha256 = "d3e48816bebbd7bad0f6942a36fa7bcd14fe4ccbf4b3a5d54040ca0294dafd1f" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/18/f5/13f35b631325801434ba2609fe9225d1733006003519b093a233c55ebb73/tox-4.67.0-py3-none-any.whl" +upload-time = 2026-10-17 19:15:46+00:00 +size = 237718 + +[packages.wheels.hashes] +sha256 = "8fbe14416ba59543d3df3b9376537e5132fd2dd44176591285ecefb40b179533" + +[[packages]] +name = "tox-uv" +version = "1.37.0" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/2a/65/7cf72148bff33fd0a572e620c7b1573d6a5e4812e9207878d4e29e4a938a/tox_uv-1.37.0-py3-none-any.whl" +upload-time = 2026-10-11 00:33:49+00:00 +size = 6564 + +[packages.wheels.hashes] +sha256 = "f2e7de8f12d7ffd850d2a3dcebf278cf72a8952a9abe75d84cfb54a76ee17ae4" + +[[packages]] +name = "tox-uv-bare" +version = "1.37.0" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/31/27/203b4dbe73cc6dadc2480e319631310981540dabe95893de983106d7b103/tox_uv_bare-1.37.0.tar.gz" +upload-time = 2026-10-11 00:33:52+00:00 +size = 32961 + +[packages.sdist.hashes] +sha256 = "97418f2febec7da57d010ca6ffed8c7d56be9d425f381b74c7fe8d0213fccad9" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/52/4c/9596668bb448b868c684a3df9190103e9bc2f7de13e646628ac914f928be/tox_uv_bare-1.37.0-py3-none-any.whl" +upload-time = 2026-10-11 00:33:51+00:00 +size = 22322 + +[packages.wheels.hashes] +sha256 = "a596c0726690e2182cc85dfbc2f39e6fddacb8f7ea08bf8bace122a17397cd13" + +[[packages]] +name = "trove-classifiers" +version = "2026.9.21.13" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/bf/93/af436dfaa845cab5d96f0adbc1e4f3730532d37fa249e4eb796fb1d7fc82/trove_classifiers-2026.9.21.13.tar.gz" +upload-time = 2026-09-21 13:29:07+00:00 +size = 17085 + +[packages.sdist.hashes] +sha256 = "0a9ebc8d4e2f3e8a22848c5258033035bec17a3012ac3fea16dbaa764489eb71" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/30/81/0da8afb52a71d0a4f2bd3152357b1a441e393b286374802b9d3addab4ab5/trove_classifiers-2026.9.21.13-py3-none-any.whl" +upload-time = 2026-09-21 13:29:06+00:00 +size = 14232 + +[packages.wheels.hashes] +sha256 = "8b1ff4f9c191b1040b71c37f1e445ab99732911e3cd91de52838453a854d7a17" + +[[packages]] +name = "trustme" +version = "1.2.1" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/4b/c5/931476f4cf1cd9e736f32651005078061a50dc164a2569fb874e00eb2786/trustme-1.2.1.tar.gz" +upload-time = 2025-01-02 01:55:32+00:00 +size = 26844 + +[packages.sdist.hashes] +sha256 = "6528ba2bbc7f2db41f33825c8dd13e3e3eb9d334ba0f909713c8c3139f4ae47f" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b5/f3/c34dbabf6da5eda56fe923226769d40e11806952cd7f46655dd06e10f018/trustme-1.2.1-py3-none-any.whl" +upload-time = 2025-01-02 01:55:30+00:00 +size = 16530 + +[packages.wheels.hashes] +sha256 = "d768e5fc57c86dfc5ec9365102e9b092541cd6954b35d8c1eea01a84f35a762a" + +[[packages]] +name = "truststore" +version = "0.10.5" +marker = "\"lock-tools\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz" +upload-time = 2026-10-12 22:27:31+00:00 +size = 28091 + +[packages.sdist.hashes] +sha256 = "30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl" +upload-time = 2026-10-12 22:27:30+00:00 +size = 19017 + +[packages.wheels.hashes] +sha256 = "9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c" + +[[packages]] +name = "tuf" +version = "7.0.1" +marker = "\"release-attestations\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/9c/dd/52e7390cbac308e6b1cfe6be9bc3a96fe26326d5893cc297a52bb72792a9/tuf-7.0.1.tar.gz" +upload-time = 2026-09-02 06:19:52+00:00 +size = 274148 + +[packages.sdist.hashes] +sha256 = "5ada1db78da3518fa851e28588f4b2249b8662a28ae528f54afa2a1de541fb31" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/a2/10/171e306e4a19ff980b9029ce5a523881e34bc6a2a52750fb1853f49f0fb9/tuf-7.0.1-py3-none-any.whl" +upload-time = 2026-09-02 06:19:50+00:00 +size = 56253 + +[packages.wheels.hashes] +sha256 = "d30434bda6e079ab303fb30d1b3006d939a10ca34783b1573d61cd9b802fa45c" + +[[packages]] +name = "twine" +version = "7.0.0" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz" +upload-time = 2026-07-27 15:59:00+00:00 +size = 215032 + +[packages.sdist.hashes] +sha256 = "85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl" +upload-time = 2026-07-27 15:58:59+00:00 +size = 43204 + +[packages.wheels.hashes] +sha256 = "b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7" + +[[packages]] +name = "ty" +version = "0.0.89" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/67/ea/dfb9bdbc168c50a568809466596751afa0c7da60da03b8c12906d1decde4/ty-0.0.89.tar.gz" +upload-time = 2026-10-16 10:35:05+00:00 +size = 7978241 + +[packages.sdist.hashes] +sha256 = "bc1027b1ea95cca25c2a3a0b41433c809cf4b86a384d2b2434eba4bcd34548d4" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/c5/84/e199d9a3dc3c94c804ddd9e94b63dbe543ae7f39329574963b6a6a95872f/ty-0.0.89-py3-none-linux_armv6l.whl" +upload-time = 2026-10-16 10:34:24+00:00 +size = 14254879 + +[packages.wheels.hashes] +sha256 = "739b087e8873e177ffda99ff8a3600a4a7704d06523a8e88c4493021eee40b8a" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/e5/d5/5afe09bf5c83c4e3a9e5913386a460e240a7029f2ae5fc96df5b43563a91/ty-0.0.89-py3-none-macosx_10_12_x86_64.whl" +upload-time = 2026-10-16 10:34:27+00:00 +size = 13790417 + +[packages.wheels.hashes] +sha256 = "47ebc98480c8a301955e18a8bdd67ef07090071ad47349b3254f325d3d5ab4e7" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/7e/f9/88648f0ba9400dfd642c1e8dc0aeecb87945b121f3ff51f3902883c016d6/ty-0.0.89-py3-none-macosx_11_0_arm64.whl" +upload-time = 2026-10-16 10:34:29+00:00 +size = 12856900 + +[packages.wheels.hashes] +sha256 = "b0631291af5c850a15007c0ffb906d813966466e4ec12173c44977c0c5a2d51d" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/6a/89/baae2b407101c4cb02f19216123900c147af8fd87cdb1ed7eba139472734/ty-0.0.89-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" +upload-time = 2026-10-16 10:34:32+00:00 +size = 13653330 + +[packages.wheels.hashes] +sha256 = "3d768300ae16274ce656006e9199869ad68684b74400d963e1c82deddf3a22ad" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/ed/1d/293516c2e10a77134cfc40d22c3fc08edd8c5eadae9c75531ec8a9263348/ty-0.0.89-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl" +upload-time = 2026-10-16 10:34:34+00:00 +size = 13986215 + +[packages.wheels.hashes] +sha256 = "3dd4c57ba58bab8f6c816b2c254db52b58834b5dbf8268287382f7a2f9735c8f" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/2f/c3/da911c8fc22d2664692432570619576b25c7cd0a586401cab08987763094/ty-0.0.89-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl" +upload-time = 2026-10-16 10:34:36+00:00 +size = 14905008 + +[packages.wheels.hashes] +sha256 = "1af774c24117d366b69142d4f35edee518617f8b926d13560d0334cbe5aa3f85" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/5c/c3/aed8d920b9bbf7b582e9fc0305acb2f2c748ede788e7dba9d6952e939c78/ty-0.0.89-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" +upload-time = 2026-10-16 10:34:39+00:00 +size = 15278217 + +[packages.wheels.hashes] +sha256 = "a8147811b9b3bc2dc9338cd01c7893ac931fc3fae7aace169047ecbd627e6628" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/96/92/c6b4320f08fca44ffe279e5ddeb3eae12869c6341f596931accdefea1182/ty-0.0.89-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl" +upload-time = 2026-10-16 10:34:41+00:00 +size = 15206289 + +[packages.wheels.hashes] +sha256 = "6d5f178c91d50fb62461b5e579028b7d2e92872e1d8f02e96ace26464c2277a6" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/3e/61/da22c714fd4f4dff642d60fb0d8bf8da1985d126f21cc89237f8bf707c92/ty-0.0.89-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-10-16 10:34:44+00:00 +size = 14510395 + +[packages.wheels.hashes] +sha256 = "95a410f10b26cc3861757b626727db9d979deba7d2a3ab59f964fac8f0514fdc" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/f0/32/5f9ce2377a77ad9ff36d8996edf86b1f069c1e8716e37c7d6d668402b110/ty-0.0.89-py3-none-manylinux_2_31_riscv64.whl" +upload-time = 2026-10-16 10:34:46+00:00 +size = 14767366 + +[packages.wheels.hashes] +sha256 = "a70ab1a402746d7837407dc169e461fdf90945bc1eef0d3c0f1be543490060bf" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/63/72/2b28c71d6ebd97b5bb1378f1806598c5dd90278f7b99a431dd3879dddcfc/ty-0.0.89-py3-none-musllinux_1_2_aarch64.whl" +upload-time = 2026-10-16 10:34:48+00:00 +size = 13670284 + +[packages.wheels.hashes] +sha256 = "b55c28a49bd2676eac9b3540a1dcc4cc053e4671c65dcec9fdad3dfe55fb148c" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/34/cc/68b8ebf98c9bc582fddc3d2b93f6e0f53719d1ddee1c5037dfa2e1cb8467/ty-0.0.89-py3-none-musllinux_1_2_armv7l.whl" +upload-time = 2026-10-16 10:34:50+00:00 +size = 13997891 + +[packages.wheels.hashes] +sha256 = "4c9f22edeb8176f8ab189337e8cbd8f92b66744534a2886df9e252b52ade991c" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/cf/4d/a1d61e06092fd5726f48a8711bd32eac6af132308eb222a0027caba4f42d/ty-0.0.89-py3-none-musllinux_1_2_i686.whl" +upload-time = 2026-10-16 10:34:53+00:00 +size = 14326247 + +[packages.wheels.hashes] +sha256 = "0c186185cad57a1469b851096b51345de7b625e353c1f4acd338402daed22fa7" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/f8/d2/466d3762e330504a2c114dbbd0f0383229f9652dccd56277dde008bfe0da/ty-0.0.89-py3-none-musllinux_1_2_x86_64.whl" +upload-time = 2026-10-16 10:34:55+00:00 +size = 14661527 + +[packages.wheels.hashes] +sha256 = "5e38302ac218d2404758241093b5542ed0345d4f569096589f1810034244e998" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b1/d5/dfd7ed5e4af472b20a11ef651da4bca55f9dc8be485cd152337f94caddde/ty-0.0.89-py3-none-win32.whl" +upload-time = 2026-10-16 10:34:57+00:00 +size = 13224078 + +[packages.wheels.hashes] +sha256 = "0146637ed2321717280bfe4e4b20a18813b741da280e8039d7df2bf22b0251f1" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b4/14/1ee663c422e3a2d910c5cc27e7d84bd7709a3df0aca28a713843c6c3fa5d/ty-0.0.89-py3-none-win_amd64.whl" +upload-time = 2026-10-16 10:35:00+00:00 +size = 13727433 + +[packages.wheels.hashes] +sha256 = "9fc56322a1aff29a19088d2025f3c42a6d25a7035668048bb908c9f8b66f480d" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/09/93/af8f7250920823ca6d582bcf0abf803fb92c8b99415ba7f40f2304102c6f/ty-0.0.89-py3-none-win_arm64.whl" +upload-time = 2026-10-16 10:35:02+00:00 +size = 13287101 + +[packages.wheels.hashes] +sha256 = "1d4967c8f7bfd67c9708115b41d7049197c5d20c76fb2e136fe78b0437ada05b" + +[[packages]] +name = "typing-extensions" +version = "4.16.0" +marker = "\"lock-tools\" in dependency_groups or \"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz" +upload-time = 2026-07-02 08:40:05+00:00 +size = 113555 + +[packages.sdist.hashes] +sha256 = "dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl" +upload-time = 2026-07-02 08:40:04+00:00 +size = 45571 + +[packages.wheels.hashes] +sha256 = "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" + +[[packages]] +name = "typing-inspection" +version = "0.4.4" +marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz" +upload-time = 2026-08-12 12:37:25+00:00 +size = 76928 + +[packages.sdist.hashes] +sha256 = "547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl" +upload-time = 2026-08-12 12:37:24+00:00 +size = 14750 + +[packages.wheels.hashes] +sha256 = "65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147" + +[[packages]] +name = "urllib3" +version = "2.8.0" +marker = "\"lock-tools\" in dependency_groups or \"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz" +upload-time = 2026-09-15 19:29:36+00:00 +size = 458972 + +[packages.sdist.hashes] +sha256 = "63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl" +upload-time = 2026-09-15 19:29:34+00:00 +size = 135717 + +[packages.wheels.hashes] +sha256 = "0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3" + +[[packages]] +name = "uv" +version = "0.13.5" +marker = "\"release-resolver\" in dependency_groups or \"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/de/f0/7565e80415f841c3500ed69d86516c7676e3b24470a218ab61b1f43071a2/uv-0.13.5.tar.gz" +upload-time = 2026-10-22 01:32:33+00:00 +size = 7513309 + +[packages.sdist.hashes] +sha256 = "979409b8c9a78df140ffb19c53f511b7a73526da8d1d5e36558833055e371c6c" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/24/ff/e30478710f53cf388c892e27e90662aa2acac7a7998bdb18023c595213c1/uv-0.13.5-py3-none-linux_armv6l.whl" +upload-time = 2026-10-22 01:31:37+00:00 +size = 18325046 + +[packages.wheels.hashes] +sha256 = "8d092d07d2389909027c2ed4965ade1bd46bbcecceb26686ff40927684f8c457" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/e3/2a/dcae794203261e49564bb9ff7c4b64a093f69ac73476cc8ce54c1deed3a1/uv-0.13.5-py3-none-macosx_10_12_x86_64.whl" +upload-time = 2026-10-22 01:31:40+00:00 +size = 17335724 + +[packages.wheels.hashes] +sha256 = "337a14829bb499e114989df4f908f134ff37f1fb6c33497541cfb05b28080e39" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/d2/26/9ac45211123244ea68be852775093af487a64debc13621a2de239f8ebe73/uv-0.13.5-py3-none-macosx_11_0_arm64.whl" +upload-time = 2026-10-22 01:31:43+00:00 +size = 14470685 + +[packages.wheels.hashes] +sha256 = "a42c85fa8156e1e5e2cecdbc77bb818ea1dcf5f78b8e2a8f84c7fe4f16f0702e" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/e8/2e/5bf9acf9d11fb790420e258a875e13bba36bc890858350642aca185efe0e/uv-0.13.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl" +upload-time = 2026-10-22 01:31:46+00:00 +size = 17784583 + +[packages.wheels.hashes] +sha256 = "2386c2dc9b59fc1d39b1c500e4319d71d3268232eb3b38e29cd6775d6269e8d4" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/f3/70/b02fdcbae77346e3ec1310e7de9dde541252e709f6641db3fbd32cd4da79/uv-0.13.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl" +upload-time = 2026-10-22 01:31:50+00:00 +size = 17866408 + +[packages.wheels.hashes] +sha256 = "4f62680fe58c5a83fd539e38f1956c2b577b1f5939c9020aa4c7f0addda2f4ad" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/fd/b9/6663095851e0943e05e47318d7600e567bc172a1e7a26b21b8e6608e8491/uv-0.13.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl" +upload-time = 2026-10-22 01:31:53+00:00 +size = 17753574 + +[packages.wheels.hashes] +sha256 = "0e03f5717bdaaf5ab4e2df361d2395e36b515946f56ef4bed88801aeb9959fb0" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/e5/84/1bb89a94486b813bbeaa1b8b491e69a25fa864626711944127b186f903de/uv-0.13.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl" +upload-time = 2026-10-22 01:31:56+00:00 +size = 18502087 + +[packages.wheels.hashes] +sha256 = "f867c5ec24c563afaae730a13e46cdf74a1a0b64c134286ff99762a96e2abf40" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/a4/a1/b0b0c7247f3bb0f307eae8a28679ecd0d4b0e2757fcf6c87dea6f679a099/uv-0.13.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" +upload-time = 2026-10-22 01:31:59+00:00 +size = 19927049 + +[packages.wheels.hashes] +sha256 = "d3fd5db55c20feb672ea457361aba29ab68b9ae69a14d98ad6c138b5f86dcca4" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b4/44/6bf11de78ce18869849a14178534b900dbe4e310270b2f60df6503c03137/uv-0.13.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl" +upload-time = 2026-10-22 01:32:03+00:00 +size = 19662743 + +[packages.wheels.hashes] +sha256 = "e01481ae5212e45ee7c85fad5fc67f06b0ee0329a7c3c289dc529da884b03881" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/3a/ad/3c2a81d66d1839063662e5b6c0002ec31f8ee987dbc5295747627ca6160a/uv-0.13.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-10-22 01:32:06+00:00 +size = 17211825 + +[packages.wheels.hashes] +sha256 = "fa8d26e08e34c29c0c4e820cf3181757086d547240b4f4d31ff1d8bd810ca946" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/4c/92/d83d019c63c1ccc6ff17df2bfe76270412f9766e7e71aa4d4ccbf018683f/uv-0.13.5-py3-none-manylinux_2_28_aarch64.whl" +upload-time = 2026-10-22 01:32:10+00:00 +size = 16654136 + +[packages.wheels.hashes] +sha256 = "91e58e304b63b6c91bac5561459ab0c3ea24f17bc01ea9359d69fed72142978f" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b8/5a/d42f9d93b322ad36647eb3bf516cc528847c240f630f6df213b6221c5262/uv-0.13.5-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl" +upload-time = 2026-10-22 01:32:13+00:00 +size = 17921105 + +[packages.wheels.hashes] +sha256 = "309a74d3c488bfaa3ba7bff759da73193d8781729c0a01274f4940fd32bb5712" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/97/79/c5c2c39c78cf7a1dd6e041b3f7aa2cc4258d351d42dea06f6fee0bde5daa/uv-0.13.5-py3-none-manylinux_2_31_riscv64.whl" +upload-time = 2026-10-22 01:32:16+00:00 +size = 18103266 + +[packages.wheels.hashes] +sha256 = "469cca66de1b17c75b9b9de806a64cffb57cba5e42b1e30d0bba6fa72a146f58" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/01/2f/e5c70a05013077e5ecadb804418ddbab33f7279f96d052f79f565369b2a7/uv-0.13.5-py3-none-musllinux_1_1_i686.whl" +upload-time = 2026-10-22 01:32:19+00:00 +size = 17842061 + +[packages.wheels.hashes] +sha256 = "2386e8dbfe110ff2f8ec8bd344dd18bf39eb9eb54e1edeeb87c3b85c34229cd3" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/4f/b0/7dba2cb636d7f58e0d74e948ad1afc6b7923e84e82de025e9951a68055db/uv-0.13.5-py3-none-musllinux_1_1_x86_64.whl" +upload-time = 2026-10-22 01:32:22+00:00 +size = 18963145 + +[packages.wheels.hashes] +sha256 = "d162669e3a7811c584b03690eaa11e796ad1d309aa8229d5dfe3cbcd68a90400" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/00/7c/e38b312abaae1bd59ff5886126f48e9e223fbb9a8edf9e236ede976f67cb/uv-0.13.5-py3-none-win32.whl" +upload-time = 2026-10-22 01:32:25+00:00 +size = 16240919 + +[packages.wheels.hashes] +sha256 = "f4e55a6e2a1089fe74ed50fbdf2df0f6f336ee148ad232b05932db5deee8007e" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/32/ea/771744a784b16aa21d195cb5d5d5a2878d72fa00fc26f11ea3f9646994fe/uv-0.13.5-py3-none-win_amd64.whl" +upload-time = 2026-10-22 01:32:28+00:00 +size = 15358650 + +[packages.wheels.hashes] +sha256 = "1fb29fc46ba12033e2ef95a95ac79d649e71bbb1358419bbbcbd19d86026ef2f" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/52/67/1f31243f75ece410f2db1afdfc9a9f9722fdee2385e4a347c3ca1dee6d13/uv-0.13.5-py3-none-win_arm64.whl" +upload-time = 2026-10-22 01:32:31+00:00 +size = 15533668 + +[packages.wheels.hashes] +sha256 = "0548460d46c8667488b1f089e936f29f0edfb1cc753f9152ffbd8bc84a46aa20" + +[[packages]] +name = "virtualenv" +version = "21.14.11" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/7f/be/4d1d59d309db76b5808fcfd78bd5996f4e7241366691c38fc331a3136398/virtualenv-21.14.11.tar.gz" +upload-time = 2026-10-18 07:06:34+00:00 +size = 5503091 + +[packages.sdist.hashes] +sha256 = "593052d8477ea8c55a842387ce111bd08c72618f0c1133c302cafe335e570a57" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/ce/6b/d85da8982b41190eafcb9df91dff9d83fc6989e98af4b5be4c8f85510c95/virtualenv-21.14.11-py3-none-any.whl" +upload-time = 2026-10-18 07:06:32+00:00 +size = 5495268 + +[packages.wheels.hashes] +sha256 = "b319f8590a84e5de5f8f2c803ecd2237e9bc0ffa7c1e875e3169c107cecb7454" + +[[packages]] +name = "wcwidth" +version = "0.9.2" +marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/f0/b4/7830542634bb2d3e62aa3b586a72d5b3b6c91c3168929e7000ef3fed041d/wcwidth-0.9.2.tar.gz" +upload-time = 2026-10-05 00:24:05+00:00 +size = 955039 + +[packages.sdist.hashes] +sha256 = "ae0ef90b90f6af38b54f1fe6d58662ec33b3cb4b8391958a62416d654231727b" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/59/1e/4532a81fb9dfbf4114a816775e0a36c3a64ee1d1f4bba2094e2da50be5dc/wcwidth-0.9.2-cp310-abi3-macosx_10_9_x86_64.whl" +upload-time = 2026-10-05 00:23:22+00:00 +size = 605343 + +[packages.wheels.hashes] +sha256 = "7ef5a940bd5e30bac6e721f1a48fce0cd7bb3ece19e9c5d139e72c76c35cfd07" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/a0/07/cb6940e81134b7ed25fa312ee9ab536a63db0793b149f88a90e603ceace9/wcwidth-0.9.2-cp310-abi3-macosx_11_0_arm64.whl" +upload-time = 2026-10-05 00:23:27+00:00 +size = 609960 + +[packages.wheels.hashes] +sha256 = "ae0800c5339423cc53d33a266ad264b42ba8aaa16d4464f6e6b1bee607f50b17" + +[[packages.wheels]] +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" +upload-time = 2026-10-05 00:23:38+00:00 +size = 762566 + +[packages.wheels.hashes] +sha256 = "9e542f1f8475b78452a295495d7a5bc3ead565112e9446a64dc93462a41c2a79" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/bc/f0/b8ef7758003d66b60f093695831a86dcc726aac01ee6446ffcbda27b61e3/wcwidth-0.9.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" +upload-time = 2026-10-05 00:23:32+00:00 +size = 771632 + +[packages.wheels.hashes] +sha256 = "674b518af28d38ee645ff97b74f5760abee5fad4bac74413bfc4b881ef2ce724" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/db/6c/f940133c71427c208575910e981942bd78c98b1f7cd0d1425ca4b7457c04/wcwidth-0.9.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-10-05 00:23:40+00:00 +size = 769763 + +[packages.wheels.hashes] +sha256 = "751bef0ab404b6a1dc028b56b4b85d46486be1c55833f80da533e42dc691f389" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/92/8f/285f862826f721964ec7c42f81dc53d23afbd723a0f4cd989651f8218e25/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_aarch64.whl" +upload-time = 2026-10-05 00:23:33+00:00 +size = 782134 + +[packages.wheels.hashes] +sha256 = "c3d80f39ba4653a595edae9aa46a509d14883790a8fc23c5db221ceb207f64b7" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/c2/2d/64aa54882a5d556d3654c1f926d9118b797461033e23a158409941a37c8f/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_i686.whl" +upload-time = 2026-10-05 00:23:41+00:00 +size = 782851 + +[packages.wheels.hashes] +sha256 = "0a47e03d8293590ecce66c45dc20ff7b4b885e3c78093722239585eca0d77ab2" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/59/39/52389f6de7fe2e9c14ceb8253dd99034bd86e1c87847ea3c100a97dded9a/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_x86_64.whl" +upload-time = 2026-10-05 00:23:43+00:00 +size = 784621 + +[packages.wheels.hashes] +sha256 = "67d901a4ad99249eb775b4ee4769ca97fa405d35a75f46e83166910a47003f04" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b3/8b/20225500a076ace27bbcc8a6fd7c55125133c57a618816c7b7b8b73070b1/wcwidth-0.9.2-cp310-abi3-win32.whl" +upload-time = 2026-10-05 00:23:55+00:00 +size = 593123 + +[packages.wheels.hashes] +sha256 = "ee1fd0db9d9fd711a70f3e7765e0e04c05d26982fa05361456163062549d7da4" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/5a/d6/b0690f55ea0483530a18bac917fbadbf54f35122510446fc370f5f1c2453/wcwidth-0.9.2-cp310-abi3-win_amd64.whl" +upload-time = 2026-10-05 00:23:57+00:00 +size = 597827 + +[packages.wheels.hashes] +sha256 = "2a9746de704242bd4fdaabb31dd46b82f694a56a8d21081ad89b679a89da9fec" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/e5/11/6ecf4e9e268ab1a4ec617ffcccc2ee4a71301625f5490912dbaba462fa9c/wcwidth-0.9.2-cp310-abi3-win_arm64.whl" +upload-time = 2026-10-05 00:23:51+00:00 +size = 598415 + +[packages.wheels.hashes] +sha256 = "b9c6ab615e03723b7f8760ea2f27758d656e7e13b51515c9dca5c3e8b04612fa" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/82/8a/a28d61d910005ac93dfe48be3a0ebaa49352d88cebd25323e69e6ff2f4a8/wcwidth-0.9.2-cp315-cp315t-macosx_10_15_x86_64.whl" +upload-time = 2026-10-05 00:23:25+00:00 +size = 606575 + +[packages.wheels.hashes] +sha256 = "42dbcb76ce8af39e2c9db410ac3f9bdf4e47eb41d6f44525952f172d3d98f724" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/01/c2/a3c66bd32766c8f4d6dc47d572532ba014fe5be30489f2576aff7cada363/wcwidth-0.9.2-cp315-cp315t-macosx_11_0_arm64.whl" +upload-time = 2026-10-05 00:23:30+00:00 +size = 611045 + +[packages.wheels.hashes] +sha256 = "138e1f8898e431b2f2d7881f8ca8d75591c1d3c21aa53f54e989bd6b39811da2" + +[[packages.wheels]] +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" +upload-time = 2026-10-05 00:23:47+00:00 +size = 778143 + +[packages.wheels.hashes] +sha256 = "5175609bf8cc7398a5f48aa35207bd64ebf9f45e4c70df65f7fdc7a988041a3c" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/2f/53/525da13e8f9ff7b5b4e74ec6f8d68bdee63905796972e086c6b1b96670d2/wcwidth-0.9.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" +upload-time = 2026-10-05 00:23:36+00:00 +size = 786630 + +[packages.wheels.hashes] +sha256 = "e5f669ae8c3d969c72032f9cdee019674b666e522d45e1e2099a2e9dda4a341d" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/ef/9f/d6a0c6df354b9d93466548a65cbf4ffcb48c719bbd307504cf3e76740837/wcwidth-0.9.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-10-05 00:23:49+00:00 +size = 784839 + +[packages.wheels.hashes] +sha256 = "196b47cf32f9df27ccda6dc513237f3c2429c4c659db428d60a5bc443d10f270" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/bf/d7/3021feed1ed7926021ec134943ad3b24a2f7ea742cc9976461171482ed77/wcwidth-0.9.2-cp315-cp315t-win32.whl" +upload-time = 2026-10-05 00:24:02+00:00 +size = 600845 + +[packages.wheels.hashes] +sha256 = "0cd4f7f2e53905dcb110d213a4c8529b6733fa3d232d8c717f946cc69a10349b" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/63/80/6a03356d8ee38261e3a78cf89ee03d8e7f12c572d969237be00869e2dc73/wcwidth-0.9.2-cp315-cp315t-win_amd64.whl" +upload-time = 2026-10-05 00:24:04+00:00 +size = 605441 + +[packages.wheels.hashes] +sha256 = "33df042f96c61ed3cd5fb3742fba427553a635bc578799857a48aa79f774a0b9" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/0c/48/1a308a86a833fd12ff7a08d0d2491ff4a72c8a92d12f5ead8317630f771e/wcwidth-0.9.2-cp315-cp315t-win_arm64.whl" +upload-time = 2026-10-05 00:23:54+00:00 +size = 605885 + +[packages.wheels.hashes] +sha256 = "48719a9bc76c2f84238693fe5013571fa5beffa3621cf228f1f3a9e30dae84b8" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/9c/b4/0bfa065af506540d9d558e3e5548cff00bc1f9b24e6e2a8512498e8628de/wcwidth-0.9.2-py3-none-any.whl" +upload-time = 2026-10-05 00:23:21+00:00 +size = 301667 + +[packages.wheels.hashes] +sha256 = "89ca642c5bf0101157a09366be69fad0379db1f700ae39a920e103234573670e" + +[[packages]] +name = "werkzeug" +version = "3.1.9" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz" +upload-time = 2026-09-27 18:33:41+00:00 +size = 940188 + +[packages.sdist.hashes] +sha256 = "55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl" +upload-time = 2026-09-27 18:33:39+00:00 +size = 228700 + +[packages.wheels.hashes] +sha256 = "6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab" + +[[packages]] +name = "wheel-filename" +version = "1.4.2" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/38/be/726dab762b770d0417e505c58e26d661aac1ec0c831e483cda4817ca2417/wheel_filename-1.4.2.tar.gz" +upload-time = 2024-12-01 13:03:16+00:00 +size = 7911 + +[packages.sdist.hashes] +sha256 = "87891c465dcbb40b40394a906f01a93214bdd51aa5d25e3a9a59cae62bc298fd" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/b4/0f/6e97a3bc38cdde32e3ec49f8c0903fe3559ec9ec9db181782f0bb4417717/wheel_filename-1.4.2-py3-none-any.whl" +upload-time = 2024-12-01 13:03:00+00:00 +size = 6195 + +[packages.wheels.hashes] +sha256 = "3fa599046443d4ca830d06e3d180cd0a675d5871af0a68daa5623318bb4d17e3" + +[[packages]] +name = "xmltodict" +version = "1.0.4" +marker = "\"release-hook-check\" in dependency_groups or \"release-shell\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz" +upload-time = 2026-02-22 02:21:22+00:00 +size = 26124 + +[packages.sdist.hashes] +sha256 = "6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl" +upload-time = 2026-02-22 02:21:21+00:00 +size = 13580 + +[packages.wheels.hashes] +sha256 = "a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a" + +[[packages]] +name = "yq" +version = "4.4.5" +marker = "\"release-hook-check\" in dependency_groups or \"release-shell\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +url = "https://files.pythonhosted.org/packages/fd/be/ae37199d28a0a99eb79084f91da94f6e7e58550552361a2397c5032d5d37/yq-4.4.5.tar.gz" +upload-time = 2026-10-11 02:20:56+00:00 +size = 54535 + +[packages.sdist.hashes] +sha256 = "e17b21a927d04ae33f61a7284351b69b0a539bfb73b852a04a907b79bcd15de2" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/fd/38/cca2b04f284c788ded19a9401e3049fd1f82d0467c1d6bd361915a77c063/yq-4.4.5-py3-none-any.whl" +upload-time = 2026-10-11 02:20:55+00:00 +size = 30255 + +[packages.wheels.hashes] +sha256 = "4e8b4e54979b26554c72a7e669009017a7fdd3fb00bbae0b26f82a86636c289f" + +[tool.release-creator] +created-at = 2026-10-25 07:10:27.737557+00:00 +uv-version = "0.13.5" diff --git a/tests/fixtures/caller/pyproject.toml b/tests/fixtures/caller/pyproject.toml new file mode 100644 index 0000000..344646b --- /dev/null +++ b/tests/fixtures/caller/pyproject.toml @@ -0,0 +1,93 @@ +[project] +name = "github-workflows" +version = "0.0.0" +description = "Shared release actions for tox-dev repositories" +requires-python = ">=3.15" +dependencies = [] +license = "MIT" +license-files = [ "LICENSE" ] + +[dependency-groups] +build = [ "Cython>=3.0", "setuptools>=80" ] +lock-tools = [ "nab==0.0.22", "tomlkit>=0.13" ] +lock-reader = [ "certifi>=2026.7.22", "packaging>=26.3" ] +release-notes = [ "release-creator>=2.0.0" ] +release-resolver = [ "uv==0.13.5" ] +release-mise-editor = [ "pyyaml>=6" ] +release-news = [ "release-creator>=2.0.0", "towncrier>=26.9" ] +release-prepare = [ "release-creator>=2.0.0", "towncrier>=26.9", "prek>=0.2" ] +release-validate = [ "release-creator>=2.0.0", "prek>=0.2" ] +release-check = [ "prek>=0.2" ] +release-attestations = [ "certifi>=2026.7.22", "pypi-attestations>=0.0.30" ] +release-shell = [ "prek>=0.2", "yq>=4.4.5" ] +release-hooks = [ "release-creator>=2.0.0", "prek>=0.2" ] +release-hook-check = [ "prek>=0.2", "yq>=4.4.5" ] +test = [ + "certifi", + "check-wheel-contents>=0.6.3", + "coverage[toml]>=7.10", + "hatchling>=1.28", + "pytest>=9", + "pytest-httpserver", + "pytest-mock>=3.16", + "pytest-xdist>=3", + "pyyaml>=6", + "tomlkit>=0.13", + "tox>=4.64.7", + "tox-uv>=1.36.1", + "trustme", + "twine>=7", + "ty", +] + +[tool.release-creator.locks.exclude-newer-package] +nab = "2026-10-18T05:45:09Z" +nab-index = "2026-10-18T05:45:09Z" +nab-markersets = "2026-10-18T05:45:09Z" +nab-project = "2026-10-18T05:45:09Z" +nab-provider = "2026-10-18T05:45:09Z" +nab-resolver = "2026-10-18T05:45:09Z" +release-creator = "2026-10-25T07:08:32Z" +uv = "2026-10-22T01:32:34Z" + +[tool.ruff] +line-length = 120 + +[tool.coverage.run] +branch = true +patch = [ "subprocess" ] +source_dirs = [ "tests", "tools" ] +# The bootstrap starts coverage in subprocesses, so it runs before measurement begins. +omit = [ "*/coverage_bootstrap/*" ] + +[tool.coverage.report] +fail_under = 100 +show_missing = true + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = [ "INP001" ] # Test modules intentionally have no package marker. + +[tool.towncrier] +name = "github-workflows" +directory = "changelog.d" +filename = "CHANGELOG.md" +template = "release_creator.fragments:template.jinja2" +title_format = false +type = [ + { directory = "feature", name = "Added", showcontent = true }, + { directory = "change", name = "Changes", showcontent = true }, + { directory = "bugfix", name = "Fixed", showcontent = true }, + { directory = "doc", name = "Documentation", showcontent = true }, + { directory = "breaking", name = "Breaking changes", showcontent = true }, + { directory = "security", name = "Security", showcontent = true }, + { directory = "performance", name = "Performance", showcontent = true }, + { directory = "deprecation", name = "Deprecated", showcontent = true }, + { directory = "removal", name = "Removed", showcontent = true }, + { directory = "packaging", name = "Packaging", showcontent = true }, +] + +[tool.nab.packages.release-creator] +uploaded-prior-to = 2026-10-25T07:08:32Z + +[tool.nab.packages.uv] +uploaded-prior-to = 2026-10-22T01:32:34Z diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index 79bb775..aec4080 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -11,7 +11,7 @@ import pytest import tomlkit -from conftest import action +from conftest import CALLER, action @pytest.mark.parametrize( @@ -22,11 +22,12 @@ pytest.param("release-check\nrelease-notes", "release_creator", id="multiline-groups"), ], ) -def test_install_locked_tools_installs_groups(tmp_path: Path, action_scripts: Path, groups: str, module: str) -> None: - source = Path(__file__).resolve().parents[2] +def test_install_locked_tools_installs_groups( + tmp_path: Path, action_scripts: Path, groups: str, module: str, caller: Path +) -> None: environment = tmp_path / "tools" result = install( - source, + caller, action_scripts, tmp_path, groups=groups, @@ -54,11 +55,10 @@ def test_install_locked_tools_installs_groups(tmp_path: Path, action_scripts: Pa ], ) def test_install_locked_tools_selects_job_tools( - tmp_path: Path, action_scripts: Path, group: str, present: str, absent: str + tmp_path: Path, action_scripts: Path, group: str, present: str, absent: str, caller: Path ) -> None: - source = Path(__file__).resolve().parents[2] result = install( - source, + caller, action_scripts, tmp_path, groups=group, @@ -81,7 +81,7 @@ def test_backend_constraints_match_shared_lock(tmp_path: Path) -> None: "python", str(source / "tools/build_constraints.py"), str(tmp_path / "constraints.txt"), - str(source / "pylock.toml"), + str(CALLER / "pylock.toml"), ], text=True, capture_output=True, @@ -95,10 +95,9 @@ def test_backend_constraints_match_shared_lock(tmp_path: Path) -> None: def test_install_lock_reader_rejects_hash_mismatch(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] action_root = tmp_path / "install-locked-tools" action_root.mkdir() - lock = (source / "pylock.toml").read_text() + lock = (CALLER / "pylock.toml").read_text() package = next(package for package in tomllib.loads(lock)["packages"] if package["name"] == "packaging") (tmp_path / "pylock.toml").write_text(lock.replace(package["wheels"][0]["hashes"]["sha256"], "0" * 64, 1)) result = action( @@ -114,19 +113,17 @@ def test_install_lock_reader_rejects_hash_mismatch(tmp_path: Path, action_script assert "Hash mismatch" in result.stderr -def test_install_locked_tools_reuses_existing_environment(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] +def test_install_locked_tools_reuses_existing_environment(tmp_path: Path, action_scripts: Path, caller: Path) -> None: environment = {"GROUPS": "lock-tools", "VENV": str(tmp_path / "lock-tools"), "LOCK_SOURCE": "shared"} - first = action(source, tmp_path, action_scripts, "install-locked-tools", version="", **environment) - second = action(source, tmp_path, action_scripts, "install-locked-tools", version="", **environment) + first = action(caller, tmp_path, action_scripts, "install-locked-tools", version="", **environment) + second = action(caller, tmp_path, action_scripts, "install-locked-tools", version="", **environment) assert first.returncode == second.returncode == 0, first.stderr + second.stderr assert (tmp_path / "lock-tools" / ("Scripts/nab.exe" if os.name == "nt" else "bin/nab")).is_file() -def test_install_locked_tools_keeps_reader_after_nab_sync(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] - first = install(source, action_scripts, tmp_path, groups="lock-tools", venv=tmp_path / "lock-tools") - second = install(source, action_scripts, tmp_path, groups="release-news", venv=tmp_path / "news-tools") +def test_install_locked_tools_keeps_reader_after_nab_sync(tmp_path: Path, action_scripts: Path, caller: Path) -> None: + first = install(caller, action_scripts, tmp_path, groups="lock-tools", venv=tmp_path / "lock-tools") + second = install(caller, action_scripts, tmp_path, groups="release-news", venv=tmp_path / "news-tools") assert first.returncode == second.returncode == 0, first.stderr + second.stderr reader = tmp_path / "lock-reader" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") @@ -134,10 +131,9 @@ def test_install_locked_tools_keeps_reader_after_nab_sync(tmp_path: Path, action assert imported.returncode == 0, imported.stderr -def test_install_locked_tools_exposes_release_commands(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] +def test_install_locked_tools_exposes_release_commands(tmp_path: Path, action_scripts: Path, caller: Path) -> None: environment = tmp_path / "tools" - result = install(source, action_scripts, tmp_path, groups="release-check", venv=environment) + result = install(caller, action_scripts, tmp_path, groups="release-check", venv=environment) assert result.returncode == 0, result.stderr overlay = Path((tmp_path / "github-path").read_text().splitlines()[-1]) @@ -147,9 +143,8 @@ def test_install_locked_tools_exposes_release_commands(tmp_path: Path, action_sc assert command.returncode == 0, command.stderr -def test_install_locked_tools_uses_minimal_reader(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] - result = install(source, action_scripts, tmp_path, groups="release-check", venv=tmp_path / "tools") +def test_install_locked_tools_uses_minimal_reader(tmp_path: Path, action_scripts: Path, caller: Path) -> None: + result = install(caller, action_scripts, tmp_path, groups="release-check", venv=tmp_path / "tools") assert result.returncode == 0, result.stderr reader = tmp_path / "lock-reader" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") @@ -163,9 +158,8 @@ def test_install_locked_tools_uses_minimal_reader(tmp_path: Path, action_scripts assert packages.stdout.strip() == "True" -def test_install_locked_tools_preserves_runner_python(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] - result = install(source, action_scripts, tmp_path, groups="release-check", venv=tmp_path / "tools") +def test_install_locked_tools_preserves_runner_python(tmp_path: Path, action_scripts: Path, caller: Path) -> None: + result = install(caller, action_scripts, tmp_path, groups="release-check", venv=tmp_path / "tools") assert result.returncode == 0, result.stderr overlay = Path((tmp_path / "github-path").read_text().splitlines()[-1]) @@ -175,10 +169,9 @@ def test_install_locked_tools_preserves_runner_python(tmp_path: Path, action_scr assert Path(original).resolve() == Path(sys.executable).resolve() -def test_install_locked_tools_exposes_separate_environments(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] +def test_install_locked_tools_exposes_separate_environments(tmp_path: Path, action_scripts: Path, caller: Path) -> None: for group in ("release-check", "release-news"): - result = install(source, action_scripts, tmp_path, groups=group, venv=tmp_path / group) + result = install(caller, action_scripts, tmp_path, groups=group, venv=tmp_path / group) assert result.returncode == 0, result.stderr overlays = [Path(line) for line in (tmp_path / "github-path").read_text().splitlines()] @@ -202,10 +195,9 @@ def test_install_locked_tools_reuses_windows_shaped_environment(tmp_path: Path, assert (tmp_path / "lock-tools/pyvenv.cfg").is_file() -def test_install_locked_tools_rejects_missing_group(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] +def test_install_locked_tools_rejects_missing_group(tmp_path: Path, action_scripts: Path, caller: Path) -> None: result = install( - source, + caller, action_scripts, tmp_path, groups="relesae", @@ -230,7 +222,7 @@ def test_install_locked_tools_checks_caller_release_creator(tmp_path: Path, loca str(lock), "release-prepare", "--shared-lock", - str(source / "pylock.toml"), + str(CALLER / "pylock.toml"), ] if local_source: command.append("--local-tool-source") @@ -247,7 +239,7 @@ def test_install_locked_tools_checks_caller_release_creator(tmp_path: Path, loca ) def test_local_tool_source_only_accepts_release_creator(tmp_path: Path, package_name: str, accepted: bool) -> None: source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((source / "pylock.toml").read_text()) + document = tomlkit.parse((CALLER / "pylock.toml").read_text()) package = next(item for item in document["packages"] if item["name"] == package_name) for field in ("version", "index", "sdist", "wheels"): package.pop(field) @@ -260,8 +252,10 @@ def test_local_tool_source_only_accepts_release_creator(tmp_path: Path, package_ str(tmp_path / "pylock.toml"), "release-prepare", "--shared-lock", - str(source / "pylock.toml"), + str(CALLER / "pylock.toml"), "--local-tool-source", + "--project", + str(CALLER / "pyproject.toml"), ], text=True, capture_output=True, @@ -289,7 +283,7 @@ def test_local_tool_source_only_accepts_release_creator(tmp_path: Path, package_ ) def test_install_locked_tools_rejects_uncooled_lock(tmp_path: Path, kind: str, message: str) -> None: source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((source / "pylock.toml").read_text()) + document = tomlkit.parse((CALLER / "pylock.toml").read_text()) if kind == "future-creation": document["tool"]["release-creator"]["created-at"] = datetime(2099, 10, 23, tzinfo=UTC) else: @@ -308,16 +302,41 @@ def test_install_locked_tools_rejects_uncooled_lock(tmp_path: Path, kind: str, m assert message in result.stderr +def test_install_locked_tools_rejects_unknown_lock_producer(tmp_path: Path) -> None: + lock = tmp_path / "pylock.toml" + lock.write_text( + 'lock-version = "1.0"\nrequires-python = ">=3.15"\ncreated-by = "test"\n' + 'dependency-groups = ["release-check"]\npackages = []\n' + ) + + result = subprocess.run( + ["python", str(Path(__file__).resolve().parents[2] / "tools/check_lock_groups.py"), str(lock), "release-check"], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert "lock has no recognized producer" in result.stderr + + def test_install_locked_tools_checks_build_group_with_selected_groups(tmp_path: Path) -> None: source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((source / "pylock.toml").read_text()) + document = tomlkit.parse((CALLER / "pylock.toml").read_text()) package = next(package for package in document["packages"] if package["name"] == "cython") package["wheels"][0]["upload-time"] = datetime(2099, 1, 1, tzinfo=UTC) lock = tmp_path / "pylock.toml" lock.write_text(tomlkit.dumps(document)) result = subprocess.run( - ["python", str(source / "tools/check_lock_groups.py"), str(lock), "release-news"], + [ + "python", + str(source / "tools/check_lock_groups.py"), + str(lock), + "release-news", + "--project", + str(CALLER / "pyproject.toml"), + ], text=True, capture_output=True, check=False, @@ -330,7 +349,7 @@ def test_install_locked_tools_checks_build_group_with_selected_groups(tmp_path: @pytest.mark.parametrize("change", ["missing", "naive"], ids=["missing", "naive"]) def test_install_locked_tools_rejects_undated_artifact(tmp_path: Path, change: str) -> None: source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((source / "pylock.toml").read_text()) + document = tomlkit.parse((CALLER / "pylock.toml").read_text()) package = next(package for package in document["packages"] if package["name"] == "cython") if change == "missing": del package["wheels"][0]["upload-time"] @@ -340,7 +359,14 @@ def test_install_locked_tools_rejects_undated_artifact(tmp_path: Path, change: s lock.write_text(tomlkit.dumps(document)) result = subprocess.run( - ["python", str(source / "tools/check_lock_groups.py"), str(lock), "release-news"], + [ + "python", + str(source / "tools/check_lock_groups.py"), + str(lock), + "release-news", + "--project", + str(CALLER / "pyproject.toml"), + ], text=True, capture_output=True, check=False, @@ -352,7 +378,7 @@ def test_install_locked_tools_rejects_undated_artifact(tmp_path: Path, change: s def test_install_locked_tools_skips_unselected_package(tmp_path: Path) -> None: source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((source / "pylock.toml").read_text()) + document = tomlkit.parse((CALLER / "pylock.toml").read_text()) package = next(package for package in document["packages"] if package["name"] == "release-creator") package["wheels"][0]["upload-time"] = datetime(2099, 1, 1, tzinfo=UTC) lock = tmp_path / "pylock.toml" @@ -376,7 +402,7 @@ def test_install_locked_tools_uses_full_lock_provenance_and_selected_groups( tmp_path: Path, group: str, accepted: bool ) -> None: source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((source / "pylock.toml").read_text()) + document = tomlkit.parse((CALLER / "pylock.toml").read_text()) document["dependency-groups"].append("test") package = next(package for package in document["packages"] if package["name"] == "release-creator") package["marker"] = '"test" in dependency_groups' @@ -403,7 +429,7 @@ def test_install_locked_tools_uses_full_lock_provenance_and_selected_groups( ) def test_install_locked_tools_rejects_non_pypi_artifact(tmp_path: Path, source_kind: str) -> None: source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((source / "pylock.toml").read_text()) + document = tomlkit.parse((CALLER / "pylock.toml").read_text()) package = next(package for package in document["packages"] if package["name"] == "cython") artifact = package["wheels"][0] if source_kind == "foreign-host": @@ -428,7 +454,7 @@ def test_install_locked_tools_rejects_non_pypi_artifact(tmp_path: Path, source_k def test_install_locked_tools_checks_exact_group_marker(tmp_path: Path) -> None: source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((source / "pylock.toml").read_text()) + document = tomlkit.parse((CALLER / "pylock.toml").read_text()) package = next(package for package in document["packages"] if package["name"] == "cython") package["marker"] = '"release-check" in dependency_groups and "build" not in dependency_groups' package["wheels"][0]["url"] = package["wheels"][0]["url"].replace("files.pythonhosted.org", "example.org") @@ -448,7 +474,7 @@ def test_install_locked_tools_checks_exact_group_marker(tmp_path: Path) -> None: def test_install_locked_tools_rejects_artifact_version_mismatch(tmp_path: Path) -> None: source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((source / "pylock.toml").read_text()) + document = tomlkit.parse((CALLER / "pylock.toml").read_text()) package = next(package for package in document["packages"] if package["name"] == "setuptools") artifact = package["wheels"][0] artifact["url"] = artifact["url"].replace(str(package["version"]), "0.0.0") @@ -466,32 +492,87 @@ def test_install_locked_tools_rejects_artifact_version_mismatch(tmp_path: Path) assert "package version" in result.stderr -def test_install_locked_tools_rejects_unreviewed_version_under_reviewed_floor(tmp_path: Path) -> None: +@pytest.mark.parametrize( + ("policy", "accepted"), + [ + pytest.param("", False, id="no-override"), + pytest.param( + "[tool.nab.packages.release-creator]\nuploaded-prior-to = 2026-10-25T07:08:32Z\n", True, id="package" + ), + pytest.param( + '[tool.nab.packages.release-creator]\nuploaded-prior-to = "2026-10-25T07:08:32+00:00"\n', True, id="text" + ), + pytest.param( + '[[tool.nab.package-rules]]\nmatch = ["release_creator"]\nuploaded-prior-to = 2026-10-25T07:08:32Z\n', + True, + id="rule", + ), + pytest.param('[tool.nab.packages.release-creator]\nuploaded-prior-to = "P0D"\n', True, id="duration"), + pytest.param("[tool.nab.packages.release-creator]\nuploaded-prior-to = false\n", True, id="disabled"), + pytest.param( + "[tool.nab.packages.release-creator]\nuploaded-prior-to = 2026-10-25T07:08:30Z\n", False, id="too-early" + ), + pytest.param('[tool.nab.packages.release-creator]\nuploaded-prior-to = "P3D"\n', False, id="long-duration"), + pytest.param("[tool.nab.packages.towncrier]\nuploaded-prior-to = false\n", False, id="other-package"), + ], +) +def test_install_locked_tools_honours_caller_package_cutoff(tmp_path: Path, policy: str, accepted: bool) -> None: + source = Path(__file__).resolve().parents[2] + project = tmp_path / "pyproject.toml" + project.write_text(f"{policy}[tool.nab.packages.uv]\nuploaded-prior-to = 2026-10-22T01:32:34Z\n") + + result = subprocess.run( + [ + "python", + str(source / "tools/check_lock_groups.py"), + str(CALLER / "pylock.toml"), + "release-news", + "--project", + str(project), + ], + text=True, + capture_output=True, + check=False, + ) + + assert (result.returncode == 0) is accepted, result.stderr + if not accepted: + assert "uncooled artifact" in result.stderr + + +@pytest.mark.parametrize( + "value", + [pytest.param("2026-10-25T07:08:32", id="naive"), pytest.param("soon", id="text"), pytest.param("7", id="number")], +) +def test_install_locked_tools_rejects_invalid_caller_cutoff(tmp_path: Path, value: str) -> None: source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((source / "pylock.toml").read_text()) - package = next(package for package in document["packages"] if package["name"] == "release-creator") - current_version = str(package["version"]) - package["version"] = "0.6.3" - for artifact in [package["sdist"], *package["wheels"]]: - artifact["url"] = artifact["url"].replace(current_version, "0.6.3") - lock = tmp_path / "pylock.toml" - lock.write_text(tomlkit.dumps(document)) + project = tmp_path / "pyproject.toml" + project.write_text( + "[tool.nab.packages.release-creator]\nuploaded-prior-to = " + (value if value == "7" else f'"{value}"') + "\n" + ) result = subprocess.run( - ["python", str(source / "tools/check_lock_groups.py"), str(lock), "release-news"], + [ + "python", + str(source / "tools/check_lock_groups.py"), + str(CALLER / "pylock.toml"), + "release-news", + "--project", + str(project), + ], text=True, capture_output=True, check=False, ) assert result.returncode != 0 - assert "uncooled artifact" in result.stderr + assert "invalid Nab cutoff for release-creator" in result.stderr @pytest.mark.parametrize("version", ["0.6.0", "0.6.4"]) def test_install_locked_tools_rejects_caller_below_shared_floor(tmp_path: Path, version: str) -> None: source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((source / "pylock.toml").read_text()) + document = tomlkit.parse((CALLER / "pylock.toml").read_text()) package = next(package for package in document["packages"] if package["name"] == "release-creator") current_version = str(package["version"]) package["version"] = version @@ -508,7 +589,7 @@ def test_install_locked_tools_rejects_caller_below_shared_floor(tmp_path: Path, str(lock), "release-news", "--shared-lock", - str(source / "pylock.toml"), + str(CALLER / "pylock.toml"), ], text=True, capture_output=True, @@ -520,9 +601,8 @@ def test_install_locked_tools_rejects_caller_below_shared_floor(tmp_path: Path, def test_install_locked_tools_constraints_only_checks_lock_age(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] - (tmp_path / "pyproject.toml").write_bytes((source / "pyproject.toml").read_bytes()) - document = tomlkit.parse((source / "pylock.toml").read_text()) + (tmp_path / "pyproject.toml").write_bytes((CALLER / "pyproject.toml").read_bytes()) + document = tomlkit.parse((CALLER / "pylock.toml").read_text()) document["tool"]["release-creator"]["created-at"] = datetime(2099, 10, 23, tzinfo=UTC) (tmp_path / "pylock.toml").write_text(tomlkit.dumps(document)) result = action(tmp_path, tmp_path, action_scripts, "install-locked-tools", version="") @@ -545,7 +625,7 @@ def test_install_locked_tools_constraints_only_accepts_no_build_group(tmp_path: def test_caller_version_check_uses_shared_package_marker(tmp_path: Path) -> None: source = Path(__file__).resolve().parents[2] shared = tmp_path / "shared.toml" - lock = (source / "pylock.toml").read_text() + lock = (CALLER / "pylock.toml").read_text() marker = next( package["marker"] for package in tomllib.loads(lock)["packages"] if package["name"] == "release-creator" ) @@ -575,7 +655,7 @@ def test_caller_version_check_uses_shared_package_marker(tmp_path: Path) -> None def test_caller_version_check_covers_other_platform(tmp_path: Path) -> None: source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((source / "pylock.toml").read_text()) + document = tomlkit.parse((CALLER / "pylock.toml").read_text()) package = next(item for item in document["packages"] if item["name"] == "release-creator") package["marker"] = '"release-shell" in dependency_groups and sys_platform == "win32"' shared = tmp_path / "shared.toml" @@ -607,7 +687,7 @@ def test_caller_version_check_covers_other_platform(tmp_path: Path) -> None: def test_caller_version_check_uses_highest_shared_platform_floor(tmp_path: Path) -> None: source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((source / "pylock.toml").read_text()) + document = tomlkit.parse((CALLER / "pylock.toml").read_text()) package = next(item for item in document["packages"] if item["name"] == "release-creator") current = deepcopy(package) current_version = str(package["version"]) @@ -642,8 +722,7 @@ def test_caller_version_check_uses_highest_shared_platform_floor(tmp_path: Path) def test_install_locked_tools_shared_group_ignores_missing_caller_group(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] - lock = (source / "pylock.toml").read_text().replace(' "release-attestations",\n', "", 1) + lock = (CALLER / "pylock.toml").read_text().replace(' "release-attestations",\n', "", 1) (tmp_path / "pylock.toml").write_text(lock) assert "release-attestations" not in tomllib.loads(lock)["dependency-groups"] result = install( @@ -671,11 +750,10 @@ def test_install_locked_tools_rejects_unknown_lock_source(tmp_path: Path, action assert "unknown lock source: unknown" in result.stderr -def test_install_locked_tools_reuses_exporter(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] +def test_install_locked_tools_reuses_exporter(tmp_path: Path, action_scripts: Path, caller: Path) -> None: for target in ("notes", "validators"): result = install( - source, + caller, action_scripts, tmp_path, groups="release-notes", @@ -685,8 +763,7 @@ def test_install_locked_tools_reuses_exporter(tmp_path: Path, action_scripts: Pa assert (tmp_path / "validators/bin/release-creator").is_file() -def test_install_locked_tools_uses_managed_python(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] +def test_install_locked_tools_uses_managed_python(tmp_path: Path, action_scripts: Path, caller: Path) -> None: tools = tmp_path / "bin" tools.mkdir() for name in ("bash", "sha256sum", "uv"): @@ -694,7 +771,7 @@ def test_install_locked_tools_uses_managed_python(tmp_path: Path, action_scripts assert executable is not None (tools / name).symlink_to(executable) result = install( - source, + caller, action_scripts, tmp_path, groups="release-check", @@ -705,10 +782,11 @@ def test_install_locked_tools_uses_managed_python(tmp_path: Path, action_scripts assert (tmp_path / "tools/bin/prek").is_file() -def test_install_locked_tools_exporter_does_not_build_pyyaml(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] +def test_install_locked_tools_exporter_does_not_build_pyyaml( + tmp_path: Path, action_scripts: Path, caller: Path +) -> None: result = install( - source, + caller, action_scripts, tmp_path, groups="release-notes", @@ -725,9 +803,8 @@ def test_install_locked_tools_exporter_does_not_build_pyyaml(tmp_path: Path, act def test_install_locked_tools_merges_shared_and_caller_build_constraints(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] - (tmp_path / "pyproject.toml").write_bytes((source / "pyproject.toml").read_bytes()) - (tmp_path / "pylock.toml").write_bytes((source / "pylock.toml").read_bytes()) + (tmp_path / "pyproject.toml").write_bytes((CALLER / "pyproject.toml").read_bytes()) + (tmp_path / "pylock.toml").write_bytes((CALLER / "pylock.toml").read_bytes()) (tmp_path / "pylock.toml").write_text( (tmp_path / "pylock.toml").read_text() + """ @@ -755,9 +832,8 @@ def test_install_locked_tools_merges_shared_and_caller_build_constraints(tmp_pat def test_install_locked_tools_accepts_lock_without_build_group(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] - (tmp_path / "pyproject.toml").write_bytes((source / "pyproject.toml").read_bytes()) - document = tomlkit.parse((source / "pylock.toml").read_text()) + (tmp_path / "pyproject.toml").write_bytes((CALLER / "pyproject.toml").read_bytes()) + document = tomlkit.parse((CALLER / "pylock.toml").read_text()) document["dependency-groups"].remove("build") (tmp_path / "pylock.toml").write_text(tomlkit.dumps(document)) result = install(tmp_path, action_scripts, tmp_path, groups="release-check", venv=tmp_path / "tools") @@ -765,10 +841,11 @@ def test_install_locked_tools_accepts_lock_without_build_group(tmp_path: Path, a assert "setuptools==" in next(tmp_path.glob("build-constraints-*.txt")).read_text() -def test_install_locked_tools_ignores_resolution_cutoff_for_hashed_lock(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] +def test_install_locked_tools_ignores_resolution_cutoff_for_hashed_lock( + tmp_path: Path, action_scripts: Path, caller: Path +) -> None: result = install( - source, + caller, action_scripts, tmp_path, groups="release-notes", @@ -778,7 +855,7 @@ def test_install_locked_tools_ignores_resolution_cutoff_for_hashed_lock(tmp_path assert result.returncode == 0, result.stderr locked_version = next( package["version"] - for package in tomllib.loads((source / "pylock.toml").read_text())["packages"] + for package in tomllib.loads((CALLER / "pylock.toml").read_text())["packages"] if package["name"] == "release-creator" ) installed_version = subprocess.run( @@ -795,7 +872,7 @@ def test_install_locked_tools_ignores_resolution_cutoff_for_hashed_lock(tmp_path def test_shared_lock_records_upload_times() -> None: - packages = tomllib.loads((Path(__file__).resolve().parents[2] / "pylock.toml").read_text())["packages"] + packages = tomllib.loads((CALLER / "pylock.toml").read_text())["packages"] missing = { package["name"] for package in packages @@ -805,10 +882,9 @@ def test_shared_lock_records_upload_times() -> None: def test_shared_lock_respects_seven_day_cutoff() -> None: - source = Path(__file__).resolve().parents[2] - overrides = tomllib.loads((source / "pyproject.toml").read_text())["tool"]["release-creator"]["locks"] + overrides = tomllib.loads((CALLER / "pyproject.toml").read_text())["tool"]["release-creator"]["locks"] floors = {name: datetime.fromisoformat(value) for name, value in overrides.get("exclude-newer-package", {}).items()} - lock = tomllib.loads((source / "pylock.toml").read_text()) + lock = tomllib.loads((CALLER / "pylock.toml").read_text()) cutoff = datetime.now(UTC) - timedelta(days=7) packages = lock["packages"] younger = { diff --git a/tests/validate_news/test_lock_groups.py b/tests/install_locked_tools/test_workspace_sources.py similarity index 72% rename from tests/validate_news/test_lock_groups.py rename to tests/install_locked_tools/test_workspace_sources.py index 3e19182..b2044d4 100644 --- a/tests/validate_news/test_lock_groups.py +++ b/tests/install_locked_tools/test_workspace_sources.py @@ -1,6 +1,5 @@ from __future__ import annotations -import runpy import subprocess import sys from pathlib import Path @@ -8,49 +7,11 @@ import pytest -from tools.lock_groups import validate_workspace_sources +from tools.check_lock_groups import validate_workspace_sources _TOOLS: Final = Path(__file__).resolve().parents[2] / "tools" -def _producer(lock: Path) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [sys.executable, str(_TOOLS / "lock_groups.py"), str(lock)], capture_output=True, text=True, check=False - ) - - -def test_lock_groups_accepts_release_creator_lock(tmp_path: Path) -> None: - lock = tmp_path / "pylock.toml" - lock.write_text("[tool.release-creator]\ncreated-at = 2026-10-24T00:00:00Z\n") - - result = _producer(lock) - - assert result.returncode == 0, result.stderr - - -@pytest.mark.parametrize( - ("content", "message"), - [ - pytest.param('[tool.nab]\ncommand-line = ["nab", "lock"]\n', "must come from release-creator locks", id="nab"), - pytest.param('lock-version = "1.0"\n', "no recognized producer", id="unknown"), - ], -) -def test_lock_groups_rejects_other_producer(tmp_path: Path, content: str, message: str) -> None: - lock = tmp_path / "pylock.toml" - lock.write_text(content) - - result = _producer(lock) - - assert result.returncode != 0 - assert message in result.stderr - - -def test_lock_groups_import_has_no_cli_side_effect(capsys: pytest.CaptureFixture[str]) -> None: - runpy.run_path(str(_TOOLS / "lock_groups.py"), run_name="lock_groups") - - assert capsys.readouterr() == ("", "") - - def test_workspace_sources_accept_declared_member(tmp_path: Path) -> None: (tmp_path / "pyproject.toml").write_text( '[project]\nname = "bare"\n[tool.uv.workspace]\nmembers = ["packages/*"]\nexclude = ["packages/unused"]\n' diff --git a/tests/refresh_release_lock/test_check_nab_lock.py b/tests/refresh_release_lock/test_check_nab_lock.py new file mode 100644 index 0000000..5e4b03f --- /dev/null +++ b/tests/refresh_release_lock/test_check_nab_lock.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest +import tomlkit + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from tools.check_nab_lock import main as check_nab_lock + + +@pytest.fixture +def nab_lock_pair(tmp_path: Path) -> tuple[Path, Path]: + committed = tmp_path / "committed.toml" + regenerated = tmp_path / "regenerated.toml" + committed.write_text( + 'environments = ["python_version == 3.15"]\npackages = ["safe"]\n' + '[tool.nab]\ncreated-at = 2026-10-01T00:00:00Z\ncommand-line = ["--upgrade"]\n' + ) + regenerated.write_bytes(committed.read_bytes()) + return committed, regenerated + + +def test_check_nab_lock_restores_committed_bytes(nab_lock_pair: tuple[Path, Path]) -> None: + committed, regenerated = nab_lock_pair + regenerated.write_text(regenerated.read_text().replace('command-line = ["--upgrade"]', "command-line = []")) + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py"), + str(committed), + str(regenerated), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert regenerated.read_bytes() == committed.read_bytes() + + +def test_check_nab_lock_environments_only_preserves_other_changes( + nab_lock_pair: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + committed, regenerated = nab_lock_pair + changed = regenerated.read_text().replace('packages = ["safe"]', 'packages = ["changed"]') + regenerated.write_text(changed) + monkeypatch.setattr(sys, "argv", ["check-nab-lock", str(committed), str(regenerated)]) + with pytest.raises(SystemExit) as error: + check_nab_lock() + assert error.value.code == 2 + monkeypatch.setattr(sys, "argv", ["check-nab-lock", "--environments-only", str(committed), str(regenerated)]) + check_nab_lock() + assert regenerated.read_text() == changed + + +def test_check_nab_lock_environments_only_rejects_target_change(nab_lock_pair: tuple[Path, Path]) -> None: + committed, regenerated = nab_lock_pair + regenerated.write_text(regenerated.read_text().replace("python_version == 3.15", "python_version == 3.14")) + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py"), + "--environments-only", + str(committed), + str(regenerated), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "changed its target environments" in result.stderr + + +def test_check_nab_lock_reports_invalid_encoding(nab_lock_pair: tuple[Path, Path]) -> None: + committed, regenerated = nab_lock_pair + committed.write_bytes(b"\xff") + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py"), + str(committed), + str(regenerated), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "utf-8" in result.stderr + + +def test_check_nab_lock_rejects_package_change(tmp_path: Path) -> None: + source = Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py" + committed = tmp_path / "committed.toml" + generated = tmp_path / "generated.toml" + committed.write_text( + 'packages = ["safe"]\n[tool.nab]\ncreated-at = 2026-10-01T00:00:00Z\ncommand-line = ["--upgrade"]\n' + ) + generated.write_text('packages = ["changed"]\n[tool.nab]\ncreated-at = 2026-10-01T00:00:00Z\ncommand-line = []\n') + result = subprocess.run( + [sys.executable, str(source), str(committed), str(generated)], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "regenerated Nab lock differs" in result.stderr + + +def test_check_nab_lock_rejects_future_creation(tmp_path: Path) -> None: + source = Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py" + lock = tmp_path / "pylock.toml" + lock.write_text("[tool.nab]\ncreated-at = 2099-01-01T00:00:00Z\n") + result = subprocess.run( + [sys.executable, str(source), str(lock), str(lock)], text=True, capture_output=True, check=False + ) + assert result.returncode != 0 + assert "created in the future" in result.stderr + + +def test_check_nab_lock_rejects_forged_upload_time( + build_repository: Path, nab_executable: Path, tmp_path: Path +) -> None: + source = Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py" + lock = build_repository / "pylock.toml" + document = tomlkit.parse(lock.read_text()) + package = next(item for item in document["packages"] if item["name"] == "hatchling") + package["wheels"][0]["upload-time"] = "2020-01-01T00:00:00Z" + lock.write_text(tomlkit.dumps(document)) + committed = tmp_path / "committed.toml" + committed.write_bytes(lock.read_bytes()) + + regenerated = subprocess.run( + [str(nab_executable), "lock", "--no-cache", "--groups", "release-notes", "--output", "pylock.toml"], + cwd=build_repository, + text=True, + capture_output=True, + check=False, + ) + assert regenerated.returncode == 0, regenerated.stderr + result = subprocess.run( + [sys.executable, str(source), str(committed), str(lock)], text=True, capture_output=True, check=False + ) + assert result.returncode != 0 + assert "regenerated Nab lock differs" in result.stderr diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py deleted file mode 100644 index ee3c43f..0000000 --- a/tests/refresh_release_lock/test_cutoffs.py +++ /dev/null @@ -1,806 +0,0 @@ -from __future__ import annotations - -import importlib -import runpy -import subprocess -import sys -import tomllib -from collections.abc import Callable -from datetime import UTC, datetime, timedelta -from pathlib import Path -from types import FunctionType -from typing import cast - -import pytest -import tomlkit -from pytest_mock import MockerFixture - -sys.path.insert(0, str(Path(__file__).resolve().parents[2])) -from tools.check_nab_lock import main as check_nab_lock -from tools.nab_cutoffs import ( - remove_expired_cutoffs, - remove_expired_release_creator_cutoffs, - validate_recent_cutoffs, - validate_release_creator_cutoffs, -) - - -@pytest.fixture -def cutoff_project(tmp_path: Path) -> Path: - project = tmp_path / "pyproject.toml" - project.write_text( - '[project]\nname = "example"\nversion = "0.1"\n' - '[tool.nab]\nuploaded-prior-to = "P7D"\n' - '[tool.nab.packages.nab]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n' - '[tool.nab.packages.old]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n' - '[tool.nab.packages.new]\nuploaded-prior-to = "2999-01-01T00:00:00Z"\n' - '[[tool.nab.package-rules]]\nmatch = ["nab-index"]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n' - '[[tool.nab.package-rules]]\nmatch = ["old-rule"]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n' - '[[tool.nab.package-rules]]\nmatch = ["new-rule"]\nuploaded-prior-to = "2999-01-01T00:00:00Z"\n', - ) - subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, capture_output=True) - subprocess.run(["git", "add", "pyproject.toml"], cwd=tmp_path, check=True, capture_output=True) - subprocess.run( - ["git", "-c", "user.name=Release test", "-c", "user.email=release@example.org", "commit", "-m", "Add policy"], - cwd=tmp_path, - check=True, - capture_output=True, - ) - return project - - -def test_expire_cutoffs_preserves_older_deliberate_holds(cutoff_project: Path) -> None: - source = Path(__file__).resolve().parents[2] / "tools" - result = subprocess.run( - [sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)], - text=True, - capture_output=True, - check=False, - ) - assert result.returncode == 0, result.stderr - nab = tomllib.loads(cutoff_project.read_text())["tool"]["nab"] - assert set(nab["packages"]) == {"nab", "old", "new"} - assert [rule["match"] for rule in nab["package-rules"]] == [["nab-index"], ["old-rule"], ["new-rule"]] - - -def test_expire_cutoffs_removes_empty_package_table(cutoff_project: Path) -> None: - project = cutoff_project.read_text().replace( - '[tool.nab.packages.nab]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n', - '[tool.nab.packages.nab]\nuploaded-prior-to = "2026-10-18T05:45:09Z"\n', - ) - project = project.replace('[tool.nab.packages.old]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n', "") - project = project.replace('[tool.nab.packages.new]\nuploaded-prior-to = "2999-01-01T00:00:00Z"\n', "") - - updated = remove_expired_cutoffs(project, datetime(2026, 11, 1, tzinfo=UTC)) - - assert "packages" not in tomllib.loads(updated)["tool"]["nab"] - - -@pytest.mark.parametrize( - "reviewed", - [ - pytest.param("2026-10-23T19:54:50Z", id="0.6.5"), - pytest.param("2026-10-23T20:48:24Z", id="0.6.6"), - pytest.param("2026-10-23T21:40:06Z", id="0.6.7"), - pytest.param("2026-10-24T06:07:46Z", id="1.0.0"), - pytest.param("2026-10-24T09:08:40Z", id="1.1.0"), - ], -) -def test_expire_cutoffs_removes_each_reviewed_release_creator_floor(reviewed: str) -> None: - project = ( - '[tool.nab]\nuploaded-prior-to = "P7D"\n' - f'[tool.nab.packages.release-creator]\nuploaded-prior-to = "{reviewed}"\n' - ) - - updated = remove_expired_cutoffs(project, datetime(2026, 11, 1, tzinfo=UTC)) - - assert "packages" not in tomllib.loads(updated)["tool"]["nab"] - - -@pytest.mark.parametrize( - ("seconds_before", "expired"), - [ - pytest.param(1, False, id="before-threshold"), - pytest.param(0, True, id="at-threshold"), - ], -) -def test_expire_cutoffs_uses_reviewed_threshold(seconds_before: int, expired: bool) -> None: - project = ( - '[tool.nab]\nuploaded-prior-to = "P7D"\n' - '[tool.nab.packages.release-creator]\nuploaded-prior-to = "2026-10-23T19:54:50Z"\n' - ) - threshold = datetime(2026, 10, 23, 19, 54, 50, tzinfo=UTC) - timedelta(seconds=seconds_before) - updated = remove_expired_cutoffs(project, threshold) - assert ("packages" not in tomllib.loads(updated)["tool"]["nab"]) is expired - - -def test_expire_cutoffs_accepts_toml_datetime(cutoff_project: Path) -> None: - cutoff_project.write_text( - cutoff_project.read_text().replace( - '[tool.nab.packages.nab]\nuploaded-prior-to = "2026-01-01T00:00:00Z"', - "[tool.nab.packages.nab]\nuploaded-prior-to = 2026-10-18T05:45:09Z", - ), - ) - updated = remove_expired_cutoffs(cutoff_project.read_text(), datetime(2026, 11, 1, tzinfo=UTC)) - - assert "nab" not in tomllib.loads(updated)["tool"]["nab"]["packages"] - - -@pytest.mark.parametrize( - ("cutoff", "accepted"), - [ - pytest.param("2026-10-24T09:08:40Z", True, id="reviewed"), - pytest.param("2026-10-24T09:08:41Z", False, id="unreviewed"), - ], -) -def test_release_creator_cutoffs_require_review(cutoff: str, accepted: bool) -> None: - project = f'[tool.release-creator.locks.exclude-newer-package]\nrelease-creator = "{cutoff}"\n' - - if accepted: - validate_release_creator_cutoffs(project, datetime(2026, 10, 17, tzinfo=UTC)) - else: - with pytest.raises(ValueError, match="unreviewed recent release-creator cutoff"): - validate_release_creator_cutoffs(project, datetime(2026, 10, 17, tzinfo=UTC)) - - -@pytest.mark.parametrize( - ("cutoff", "accepted"), - [ - pytest.param("2026-10-22T01:32:34Z", True, id="reviewed"), - pytest.param("2026-10-22T01:32:35Z", False, id="unreviewed"), - ], -) -def test_uv_resolver_cutoff_requires_review(cutoff: str, accepted: bool) -> None: - nab = f'[tool.nab]\nuploaded-prior-to = "P7D"\n[tool.nab.packages.uv]\nuploaded-prior-to = "{cutoff}"\n' - release_creator = f'[tool.release-creator.locks.exclude-newer-package]\nuv = "{cutoff}"\n' - - if accepted: - validate_recent_cutoffs(nab, datetime(2026, 10, 17, tzinfo=UTC)) - validate_release_creator_cutoffs(release_creator, datetime(2026, 10, 17, tzinfo=UTC)) - else: - with pytest.raises(ValueError, match="unreviewed recent Nab cutoff"): - validate_recent_cutoffs(nab, datetime(2026, 10, 17, tzinfo=UTC)) - with pytest.raises(ValueError, match="unreviewed recent release-creator cutoff"): - validate_release_creator_cutoffs(release_creator, datetime(2026, 10, 17, tzinfo=UTC)) - - -@pytest.mark.parametrize( - ("project_text", "accepted"), - [ - pytest.param("[tool.release-creator.locks]\n", True, id="release-creator-only"), - pytest.param('[tool.nab]\nuploaded-prior-to = "P7D"\n', True, id="secondary-nab-only"), - pytest.param("[tool.other]\n", False, id="no-policy"), - ], -) -def test_recent_cutoff_cli_checks_project_provider(tmp_path: Path, project_text: str, accepted: bool) -> None: - project = tmp_path / "pyproject.toml" - project.write_text(project_text) - result = subprocess.run( - [sys.executable, str(Path(__file__).resolve().parents[2] / "tools/check_recent_cutoffs.py"), str(project)], - capture_output=True, - text=True, - check=False, - ) - - assert (result.returncode == 0) is accepted - if not accepted: - assert "project has no lock cutoff policy" in result.stderr - - -@pytest.mark.parametrize( - ("locks", "error"), - [ - pytest.param("unexpected = true", ValueError, id="unknown-setting"), - pytest.param('exclude-newer-package = "recent"', TypeError, id="non-table"), - pytest.param("[tool.release-creator.locks.exclude-newer-package]\nexample = false", TypeError, id="non-time"), - ], -) -def test_release_creator_cutoffs_reject_invalid_policy(locks: str, error: type[Exception]) -> None: - with pytest.raises(error): - validate_release_creator_cutoffs(f"[tool.release-creator.locks]\n{locks}\n", datetime(2026, 10, 17, tzinfo=UTC)) - - -def test_expire_release_creator_cutoff_keeps_deliberate_hold() -> None: - project = ( - "[tool.release-creator.locks.exclude-newer-package]\n" - 'release-creator = "2026-10-24T06:07:46Z"\n' - 'bad-package = "2026-01-01T00:00:00Z"\n' - ) - - updated = remove_expired_release_creator_cutoffs(project, datetime(2026, 11, 1, tzinfo=UTC)) - - assert tomllib.loads(updated)["tool"]["release-creator"]["locks"]["exclude-newer-package"] == { - "bad-package": "2026-01-01T00:00:00Z" - } - - -def test_expire_release_creator_cutoff_removes_empty_override() -> None: - project = '[tool.release-creator.locks.exclude-newer-package]\nrelease-creator = "2026-10-24T09:08:40Z"\n' - updated = remove_expired_release_creator_cutoffs(project, datetime(2026, 11, 1, tzinfo=UTC)) - - assert "exclude-newer-package" not in updated - - -def test_expire_release_creator_cutoff_keeps_unexpired_override() -> None: - project = '[tool.release-creator.locks.exclude-newer-package]\nrelease-creator = "2026-10-24T09:08:40Z"\n' - - assert remove_expired_release_creator_cutoffs(project, datetime(2026, 10, 17, tzinfo=UTC)) == project - - -def test_verify_cutoff_patch_rejects_deliberate_hold_removal(cutoff_project: Path) -> None: - cutoff_project.write_text( - cutoff_project.read_text().replace('[tool.nab.packages.nab]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n', "") - ) - source = Path(__file__).resolve().parents[2] / "tools" - result = subprocess.run( - [sys.executable, str(source / "verify_cutoff_patch.py")], - cwd=cutoff_project.parent, - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode != 0 - assert "changes beyond expired Nab cutoffs" in result.stderr - - -def test_verify_cutoff_patch_accepts_only_unchanged_metadata( - cutoff_project: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - source = Path(__file__).resolve().parents[2] / "tools" - monkeypatch.syspath_prepend(str(source)) - script = source / "verify_cutoff_patch.py" - command = runpy.run_path(str(script), run_name="verify_cutoff_imported") - monkeypatch.chdir(cutoff_project.parent) - monkeypatch.setattr(sys, "argv", [str(script)]) - main = cast("Callable[[], None]", command["main"]) - - main() - cutoff_project.write_text(cutoff_project.read_text().replace('name = "example"', 'name = "intruder"')) - - with pytest.raises(SystemExit, match="2"): - main() - - assert "changes beyond expired Nab cutoffs" in capsys.readouterr().err - - -def test_verify_cutoff_patch_rejects_other_metadata(cutoff_project: Path) -> None: - source = Path(__file__).resolve().parents[2] / "tools" - subprocess.run([sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)], check=True) - cutoff_project.write_text(cutoff_project.read_text().replace('name = "example"', 'name = "intruder"')) - result = subprocess.run( - [sys.executable, str(source / "verify_cutoff_patch.py")], - cwd=cutoff_project.parent, - text=True, - capture_output=True, - check=False, - ) - assert result.returncode != 0 - assert "changes beyond expired Nab cutoffs" in result.stderr - - -def test_verify_cutoff_patch_rejects_early_floor_removal(cutoff_project: Path) -> None: - source = Path(__file__).resolve().parents[2] / "tools" - text = cutoff_project.read_text() - cutoff_project.write_text(text.replace('[tool.nab.packages.new]\nuploaded-prior-to = "2999-01-01T00:00:00Z"\n', "")) - result = subprocess.run( - [sys.executable, str(source / "verify_cutoff_patch.py")], - cwd=cutoff_project.parent, - text=True, - capture_output=True, - check=False, - ) - assert result.returncode != 0 - assert "changes beyond expired Nab cutoffs" in result.stderr - - -@pytest.mark.parametrize( - ("remove", "accepted"), - [pytest.param("release-creator", True, id="reviewed-expired"), pytest.param("bad-package", False, id="hold")], -) -def test_verify_cutoff_patch_limits_release_creator_removal( - cutoff_project: Path, - monkeypatch: pytest.MonkeyPatch, - mocker: MockerFixture, - capsys: pytest.CaptureFixture[str], - remove: str, - accepted: bool, -) -> None: - cutoff_project.write_text( - cutoff_project.read_text() - + "[tool.release-creator.locks.exclude-newer-package]\n" - + 'release-creator = "2026-10-24T06:07:46Z"\n' - + 'bad-package = "2026-01-01T00:00:00Z"\n' - ) - subprocess.run(["git", "add", "pyproject.toml"], cwd=cutoff_project.parent, check=True, capture_output=True) - subprocess.run( - [ - "git", - "-c", - "user.name=Release test", - "-c", - "user.email=release@example.org", - "commit", - "-m", - "Add release policy", - ], - cwd=cutoff_project.parent, - check=True, - capture_output=True, - ) - cutoff_project.write_text(cutoff_project.read_text().replace(f'{remove} = "', f'# {remove} = "')) - script = Path(__file__).resolve().parents[2] / "tools/verify_cutoff_patch.py" - monkeypatch.syspath_prepend(str(script.parent)) - main = cast("FunctionType", runpy.run_path(str(script), run_name="cutoff_patch_test")["main"]) - clock = mocker.create_autospec(datetime) - clock.now.return_value = datetime(2026, 11, 1, tzinfo=UTC) - main.__globals__["datetime"] = clock - monkeypatch.chdir(cutoff_project.parent) - monkeypatch.setattr(sys, "argv", [str(script)]) - - if accepted: - main() - else: - with pytest.raises(SystemExit, match="2"): - main() - assert "changes beyond expired package cutoffs" in capsys.readouterr().err - - -def test_verify_cutoff_patch_accepts_release_creator_only_policy( - cutoff_project: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture -) -> None: - cutoff_project.write_text( - '[tool.release-creator.locks.exclude-newer-package]\nrelease-creator = "2026-10-24T09:08:40Z"\n' - ) - subprocess.run(["git", "add", "pyproject.toml"], cwd=cutoff_project.parent, check=True, capture_output=True) - subprocess.run( - [ - "git", - "-c", - "user.name=Release test", - "-c", - "user.email=release@example.org", - "commit", - "-m", - "Add release policy", - ], - cwd=cutoff_project.parent, - check=True, - capture_output=True, - ) - cutoff_project.write_text("[tool.release-creator.locks]\n") - script = Path(__file__).resolve().parents[2] / "tools/verify_cutoff_patch.py" - monkeypatch.syspath_prepend(str(script.parent)) - main = cast("FunctionType", runpy.run_path(str(script), run_name="cutoff_patch_test")["main"]) - clock = mocker.create_autospec(datetime) - clock.now.return_value = datetime(2026, 11, 1, tzinfo=UTC) - main.__globals__["datetime"] = clock - monkeypatch.chdir(cutoff_project.parent) - monkeypatch.setattr(sys, "argv", [str(script)]) - - main() - - -@pytest.fixture -def nab_lock_pair(tmp_path: Path) -> tuple[Path, Path]: - committed = tmp_path / "committed.toml" - regenerated = tmp_path / "regenerated.toml" - committed.write_text( - 'environments = ["python_version == 3.15"]\npackages = ["safe"]\n' - '[tool.nab]\ncreated-at = 2026-10-01T00:00:00Z\ncommand-line = ["--upgrade"]\n' - ) - regenerated.write_bytes(committed.read_bytes()) - return committed, regenerated - - -def test_check_nab_lock_restores_committed_bytes(nab_lock_pair: tuple[Path, Path]) -> None: - committed, regenerated = nab_lock_pair - regenerated.write_text(regenerated.read_text().replace('command-line = ["--upgrade"]', "command-line = []")) - result = subprocess.run( - [ - sys.executable, - str(Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py"), - str(committed), - str(regenerated), - ], - text=True, - capture_output=True, - check=False, - ) - assert result.returncode == 0, result.stderr - assert regenerated.read_bytes() == committed.read_bytes() - - -def test_check_nab_lock_environments_only_preserves_other_changes( - nab_lock_pair: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch -) -> None: - committed, regenerated = nab_lock_pair - changed = regenerated.read_text().replace('packages = ["safe"]', 'packages = ["changed"]') - regenerated.write_text(changed) - monkeypatch.setattr(sys, "argv", ["check-nab-lock", str(committed), str(regenerated)]) - with pytest.raises(SystemExit) as error: - check_nab_lock() - assert error.value.code == 2 - monkeypatch.setattr(sys, "argv", ["check-nab-lock", "--environments-only", str(committed), str(regenerated)]) - check_nab_lock() - assert regenerated.read_text() == changed - - -def test_check_nab_lock_environments_only_rejects_target_change(nab_lock_pair: tuple[Path, Path]) -> None: - committed, regenerated = nab_lock_pair - regenerated.write_text(regenerated.read_text().replace("python_version == 3.15", "python_version == 3.14")) - result = subprocess.run( - [ - sys.executable, - str(Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py"), - "--environments-only", - str(committed), - str(regenerated), - ], - text=True, - capture_output=True, - check=False, - ) - assert result.returncode != 0 - assert "changed its target environments" in result.stderr - - -def test_check_nab_lock_reports_invalid_encoding(nab_lock_pair: tuple[Path, Path]) -> None: - committed, regenerated = nab_lock_pair - committed.write_bytes(b"\xff") - result = subprocess.run( - [ - sys.executable, - str(Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py"), - str(committed), - str(regenerated), - ], - text=True, - capture_output=True, - check=False, - ) - assert result.returncode != 0 - assert "utf-8" in result.stderr - - -def test_check_nab_lock_rejects_package_change(tmp_path: Path) -> None: - source = Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py" - committed = tmp_path / "committed.toml" - generated = tmp_path / "generated.toml" - committed.write_text( - 'packages = ["safe"]\n[tool.nab]\ncreated-at = 2026-10-01T00:00:00Z\ncommand-line = ["--upgrade"]\n' - ) - generated.write_text('packages = ["changed"]\n[tool.nab]\ncreated-at = 2026-10-01T00:00:00Z\ncommand-line = []\n') - result = subprocess.run( - [sys.executable, str(source), str(committed), str(generated)], - text=True, - capture_output=True, - check=False, - ) - assert result.returncode != 0 - assert "regenerated Nab lock differs" in result.stderr - - -def test_check_nab_lock_rejects_future_creation(tmp_path: Path) -> None: - source = Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py" - lock = tmp_path / "pylock.toml" - lock.write_text("[tool.nab]\ncreated-at = 2099-01-01T00:00:00Z\n") - result = subprocess.run( - [sys.executable, str(source), str(lock), str(lock)], text=True, capture_output=True, check=False - ) - assert result.returncode != 0 - assert "created in the future" in result.stderr - - -def test_check_nab_lock_rejects_forged_upload_time( - build_repository: Path, nab_executable: Path, tmp_path: Path -) -> None: - source = Path(__file__).resolve().parents[2] / "tools/check_nab_lock.py" - lock = build_repository / "pylock.toml" - document = tomlkit.parse(lock.read_text()) - package = next(item for item in document["packages"] if item["name"] == "hatchling") - package["wheels"][0]["upload-time"] = "2020-01-01T00:00:00Z" - lock.write_text(tomlkit.dumps(document)) - committed = tmp_path / "committed.toml" - committed.write_bytes(lock.read_bytes()) - - regenerated = subprocess.run( - [str(nab_executable), "lock", "--no-cache", "--groups", "release-notes", "--output", "pylock.toml"], - cwd=build_repository, - text=True, - capture_output=True, - check=False, - ) - assert regenerated.returncode == 0, regenerated.stderr - result = subprocess.run( - [sys.executable, str(source), str(committed), str(lock)], text=True, capture_output=True, check=False - ) - assert result.returncode != 0 - assert "regenerated Nab lock differs" in result.stderr - - -@pytest.mark.parametrize( - ("package", "timestamp", "accepted"), - [ - pytest.param("nab", "2026-10-18T05:45:09Z", True, id="reviewed-nab"), - pytest.param("nab", "2026-10-19T00:00:00Z", False, id="unreviewed-nab"), - pytest.param("nab", "2026-10-20T00:00:00Z", False, id="extended-nab"), - pytest.param("release-creator", "2026-10-23T02:44:40Z", False, id="stale-release-creator-0.6.0"), - pytest.param("release-creator", "2026-10-23T16:52:06Z", False, id="stale-release-creator-0.6.4"), - pytest.param("release-creator", "2026-10-23T16:52:07Z", False, id="extended-release-creator"), - pytest.param("release-creator", "2026-10-23T19:54:50Z", True, id="reviewed-release-creator-0.6.5"), - pytest.param("release-creator", "2026-10-23T19:54:51Z", False, id="extended-release-creator-0.6.5"), - pytest.param("release-creator", "2026-10-23T20:48:24Z", True, id="reviewed-release-creator-0.6.6"), - pytest.param("release-creator", "2026-10-23T20:48:25Z", False, id="extended-release-creator-0.6.6"), - pytest.param("release-creator", "2026-10-23T21:40:06Z", True, id="reviewed-release-creator-0.6.7"), - pytest.param("release-creator", "2026-10-23T21:40:07Z", False, id="extended-release-creator-0.6.7"), - pytest.param("release-creator", "2026-10-24T06:07:46Z", True, id="reviewed-release-creator-1.0.0"), - pytest.param("release-creator", "2026-10-24T06:07:47Z", False, id="extended-release-creator-1.0.0"), - pytest.param("unreviewed", "2026-10-22T00:00:00Z", False, id="new-package"), - ], -) -def test_recent_cutoff_requires_review(package: str, timestamp: str, accepted: bool) -> None: - project = ( - f'[tool.nab]\nuploaded-prior-to = "P7D"\n[tool.nab.packages.{package}]\nuploaded-prior-to = "{timestamp}"\n' - ) - result = _check_recent_cutoffs(project) - assert (result.returncode == 0) == accepted - if not accepted: - assert "unreviewed recent Nab cutoff" in result.stderr - - -@pytest.mark.parametrize( - ("cutoff", "message"), - [ - pytest.param('uploaded-prior-to = "P0D"', "global Nab cutoff", id="zero-days"), - pytest.param('uploaded-prior-to = "P6D"', "global Nab cutoff", id="six-days"), - pytest.param("uploaded-prior-to = false", "global Nab cutoff", id="disabled"), - ], -) -def test_recent_cutoff_rejects_weak_global_policy(cutoff: str, message: str) -> None: - result = _check_recent_cutoffs(f"[tool.nab]\n{cutoff}\n") - assert result.returncode != 0 - assert message in result.stderr - - -@pytest.mark.parametrize( - ("value", "accepted"), - [ - pytest.param('"P0D"', False, id="zero-days"), - pytest.param('"P6D"', False, id="six-days"), - pytest.param("false", False, id="disabled"), - pytest.param('"P7D"', True, id="seven-days"), - pytest.param('"P14D"', True, id="fourteen-days"), - ], -) -def test_recent_cutoff_checks_package_duration(value: str, accepted: bool) -> None: - project = f'[tool.nab]\nuploaded-prior-to = "P7D"\n[tool.nab.packages.example]\nuploaded-prior-to = {value}\n' - result = _check_recent_cutoffs(project) - assert (result.returncode == 0) is accepted - if not accepted: - assert "weakens the seven-day policy" in result.stderr - - -@pytest.mark.parametrize("package", ["nab", "release-creator", "uv-build"], ids=["nab", "creator", "uv-build"]) -@pytest.mark.parametrize("value", ['"P0D"', "false"], ids=["zero-days", "disabled"]) -def test_reviewed_cutoff_rejects_unbounded_override(package: str, value: str) -> None: - project = f'[tool.nab]\nuploaded-prior-to = "P7D"\n[tool.nab.packages.{package}]\nuploaded-prior-to = {value}\n' - result = _check_recent_cutoffs(project) - assert result.returncode != 0 - assert "weakens the seven-day policy" in result.stderr - - -def test_recent_cutoff_rejects_index_override() -> None: - result = _check_recent_cutoffs( - '[tool.nab]\nuploaded-prior-to = "P7D"\n' - '[[tool.nab.indexes]]\nname = "pypi"\nurl = "https://pypi.org/simple/"\n' - "[tool.nab.index.pypi]\nuploaded-prior-to = false\n" - ) - assert result.returncode != 0 - assert "custom Nab indexes are not approved" in result.stderr - - -def test_recent_cutoff_rejects_custom_index() -> None: - result = _check_recent_cutoffs( - '[tool.nab]\nuploaded-prior-to = "P7D"\n' - '[[tool.nab.indexes]]\nname = "other"\nurl = "https://example.org/simple/"\n' - ) - assert result.returncode != 0 - assert "custom Nab indexes" in result.stderr - - -def test_recent_cutoff_rejects_index_settings_without_cutoff() -> None: - result = _check_recent_cutoffs( - '[tool.nab]\nuploaded-prior-to = "P7D"\n[tool.nab.index.pypi]\nallow-insecure-host = true\n' - ) - assert result.returncode != 0 - assert "custom Nab indexes" in result.stderr - - -def test_recent_cutoff_rejects_project_nab_toml( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - (tmp_path / "pyproject.toml").write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n') - (tmp_path / "nab.toml").write_text("[packages.example]\nuploaded-prior-to = false\n") - source = Path(__file__).resolve().parents[2] / "tools" - monkeypatch.syspath_prepend(str(source)) - script = source / "check_recent_cutoffs.py" - command = runpy.run_path(str(script), run_name="recent_cutoffs_imported") - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(sys, "argv", [str(script), "pyproject.toml"]) - - with pytest.raises(SystemExit, match="2"): - cast("Callable[[], None]", command["main"])() - - assert "project nab.toml can override" in capsys.readouterr().err - - -def test_verify_cutoff_patch_reports_invalid_boolean(cutoff_project: Path) -> None: - cutoff_project.write_text(cutoff_project.read_text().replace('"2026-01-01T00:00:00Z"', "true", 1)) - subprocess.run(["git", "add", "pyproject.toml"], cwd=cutoff_project.parent, check=True, capture_output=True) - subprocess.run( - ["git", "-c", "user.name=Release test", "-c", "user.email=release@example.org", "commit", "-m", "Invalid"], - cwd=cutoff_project.parent, - check=True, - capture_output=True, - ) - result = subprocess.run( - [sys.executable, str(Path(__file__).resolve().parents[2] / "tools/verify_cutoff_patch.py")], - cwd=cutoff_project.parent, - text=True, - capture_output=True, - check=False, - ) - assert result.returncode != 0 - assert "invalid cutoff for Nab: True" in result.stderr - - -def test_expire_cutoffs_preserves_disabled_package(cutoff_project: Path) -> None: - cutoff_project.write_text(cutoff_project.read_text().replace('"2999-01-01T00:00:00Z"', "false")) - source = Path(__file__).resolve().parents[2] / "tools" - result = subprocess.run( - [sys.executable, str(source / "expire_cutoffs.py"), str(cutoff_project)], - text=True, - capture_output=True, - check=False, - ) - assert result.returncode == 0, result.stderr - assert tomllib.loads(cutoff_project.read_text())["tool"]["nab"]["packages"]["new"]["uploaded-prior-to"] is False - - -@pytest.mark.parametrize( - ("value", "message"), - [ - pytest.param("true", "invalid cutoff for example", id="boolean"), - pytest.param('"2026-01-01T00:00:00"', "has no timezone", id="naive-time"), - ], -) -def test_recent_cutoff_rejects_invalid_package_time(value: str, message: str) -> None: - result = _check_recent_cutoffs( - f'[tool.nab]\nuploaded-prior-to = "P7D"\n[tool.nab.packages.example]\nuploaded-prior-to = {value}\n' - ) - assert result.returncode != 0 - assert message in result.stderr - - -def test_recent_cutoff_cli_rejects_missing_project(tmp_path: Path) -> None: - result = subprocess.run( - [ - sys.executable, - str(Path(__file__).resolve().parents[2] / "tools/check_recent_cutoffs.py"), - str(tmp_path / "missing"), - ], - text=True, - capture_output=True, - check=False, - ) - assert result.returncode != 0 - assert "No such file" in result.stderr - - -def test_expire_cutoffs_removes_reviewed_rule() -> None: - project = ( - '[tool.nab]\nuploaded-prior-to = "P7D"\n' - '[[tool.nab.package-rules]]\nmatch = ["release-creator"]\n' - 'uploaded-prior-to = "2026-10-23T19:54:50Z"\n' - ) - updated = remove_expired_cutoffs(project, datetime(2026, 11, 1, tzinfo=UTC)) - assert "package-rules" not in tomllib.loads(updated)["tool"]["nab"] - - -def test_expire_cutoffs_rejects_naive_reviewed_time() -> None: - project = "[tool.nab]\n[tool.nab.packages.nab]\nuploaded-prior-to = 2026-10-18T05:45:09\n" - with pytest.raises(ValueError, match="has no timezone"): - remove_expired_cutoffs(project, datetime(2026, 11, 1, tzinfo=UTC)) - - -def test_expire_cutoffs_keeps_disabled_reviewed_package() -> None: - project = "[tool.nab]\n[tool.nab.packages.nab]\nuploaded-prior-to = false\n" - assert remove_expired_cutoffs(project, datetime(2026, 11, 1, tzinfo=UTC)) == project - - -def test_expire_cutoffs_cli_writes_expired_rule( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture -) -> None: - monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "tools")) - module = importlib.import_module("expire_cutoffs") - project = tmp_path / "pyproject.toml" - project.write_text( - '[tool.nab]\nuploaded-prior-to = "P7D"\n' - '[[tool.nab.package-rules]]\nmatch = ["release-creator"]\n' - 'uploaded-prior-to = "2026-10-23T19:54:50Z"\n' - ) - mocker.patch.object( - module, "datetime", mocker.Mock(now=mocker.Mock(return_value=datetime(2026, 11, 1, tzinfo=UTC))) - ) - monkeypatch.setattr(sys, "argv", ["expire_cutoffs", str(project)]) - - module.main() - - assert "package-rules" not in tomllib.loads(project.read_text())["tool"]["nab"] - - -def test_expire_cutoffs_cli_writes_release_creator_policy( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture -) -> None: - monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "tools")) - module = importlib.import_module("expire_cutoffs") - project = tmp_path / "pyproject.toml" - project.write_text('[tool.release-creator.locks.exclude-newer-package]\nrelease-creator = "2026-10-24T09:08:40Z"\n') - mocker.patch.object( - module, "datetime", mocker.Mock(now=mocker.Mock(return_value=datetime(2026, 11, 1, tzinfo=UTC))) - ) - monkeypatch.setattr(sys, "argv", ["expire_cutoffs", str(project)]) - - module.main() - - assert "exclude-newer-package" not in project.read_text() - - -def test_expire_cutoffs_cli_rejects_missing_project(tmp_path: Path) -> None: - result = subprocess.run( - [ - sys.executable, - str(Path(__file__).resolve().parents[2] / "tools/expire_cutoffs.py"), - str(tmp_path / "missing"), - ], - text=True, - capture_output=True, - check=False, - ) - assert result.returncode != 0 - assert "No such file" in result.stderr - - -def test_expire_cutoffs_retains_other_rule_settings() -> None: - project = ( - '[tool.nab]\nuploaded-prior-to = "P7D"\n' - '[tool.nab.packages.nab]\nuploaded-prior-to = "2026-10-18T05:45:09Z"\nindex = "pypi"\n' - '[[tool.nab.package-rules]]\nmatch = ["nab-index"]\n' - 'uploaded-prior-to = "2026-10-18T05:45:09Z"\npriority = 1\n' - ) - - updated = tomllib.loads(remove_expired_cutoffs(project, datetime(2026, 11, 1, tzinfo=UTC))) - - assert updated["tool"]["nab"]["packages"]["nab"] == {"index": "pypi"} - assert updated["tool"]["nab"]["package-rules"] == [{"match": ["nab-index"], "priority": 1}] - - -def _check_recent_cutoffs(project: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [ - sys.executable, - "-c", - ( - "from datetime import UTC, datetime; from nab_cutoffs import validate_recent_cutoffs; " - "import sys; validate_recent_cutoffs(sys.stdin.read(), datetime(2026, 10, 16, tzinfo=UTC))" - ), - ], - cwd=Path(__file__).resolve().parents[2] / "tools", - input=project, - capture_output=True, - text=True, - check=False, - ) diff --git a/tests/refresh_release_lock/test_provider.py b/tests/refresh_release_lock/test_provider.py deleted file mode 100644 index ce01b1c..0000000 --- a/tests/refresh_release_lock/test_provider.py +++ /dev/null @@ -1,141 +0,0 @@ -from __future__ import annotations - -import os -import subprocess -import tomllib -from pathlib import Path - -import pytest -import yaml -from conftest import action, run -from packaging.version import Version - - -def test_refresh_rejects_unknown_lock_producer(tmp_path: Path, action_scripts: Path) -> None: - (tmp_path / "pylock.toml").write_text( - 'lock-version = "1.0"\ncreated-by = "test"\nrequires-python = ">=3.15"\npackages = []\n' - ) - - result = action(tmp_path, tmp_path, action_scripts, "refresh-release-lock", version="") - - assert result.returncode != 0 - assert "lock has no recognized producer" in result.stderr - - -def test_refresh_rejects_secondary_options_without_lock(tmp_path: Path, action_scripts: Path) -> None: - result = action( - tmp_path, - tmp_path, - action_scripts, - "refresh-release-lock", - version="", - ADDITIONAL_LOCK_GROUPS="build", - ) - - assert result.returncode != 0 - assert "secondary matrix inputs require additional-lock" in result.stderr - - -def test_refresh_rejects_nab_primary_lock(tmp_path: Path, action_scripts: Path) -> None: - (tmp_path / "pylock.toml").write_text('[tool.nab]\ncommand-line = ["nab", "lock"]\n') - - result = action(tmp_path, tmp_path, action_scripts, "refresh-release-lock", version="", MODE="check") - - assert result.returncode != 0 - assert "pylock.toml must come from release-creator locks" in result.stderr - - -@pytest.mark.parametrize("mode", ["check", "refresh"]) -def test_refresh_uses_the_locked_resolver(tmp_path: Path, action_scripts: Path, mode: str) -> None: - source = Path(__file__).resolve().parents[2] - steps = yaml.safe_load((source / "refresh-release-lock/action.yml").read_text())["runs"]["steps"] - packages = tomllib.loads((source / "pylock.toml").read_text())["packages"] - pinned = next(package for package in packages if package["name"] == "uv") - assert Version(pinned["version"]) >= Version("0.13.5") - project = tomllib.loads((source / "pyproject.toml").read_text()) - assert project["dependency-groups"]["release-resolver"] == [f"uv=={pinned['version']}"] - assert all(artifact["hashes"]["sha256"] for artifact in pinned["wheels"]) - - executable = tmp_path / "release-resolver/bin/uv" - executable.parent.mkdir(parents=True) - executable.write_text('#!/bin/sh\nprintf "%s\\n" "$0" >"$RUNNER_TEMP/selected-uv"\n') - executable.chmod(0o755) - resolve_step = next( - index for index, step in enumerate(steps) if step.get("name") == "Resolve all project groups and extras" - ) - result = subprocess.run( - ["bash", "-e", "-o", "pipefail", str(action_scripts / f"refresh-release-lock-{resolve_step}.sh")], - cwd=tmp_path, - env=os.environ | {"MODE": mode, "KEEP_UV_LOCK": "false", "RUNNER_TEMP": str(tmp_path)}, - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode == 0, result.stderr - assert (tmp_path / "selected-uv").read_text().strip() == str(executable) - - -@pytest.mark.parametrize("keep_uv_lock", [False, True], ids=["pylock-only", "native-uv-lock"]) -@pytest.mark.parametrize("mode", ["check", "refresh"]) -def test_refresh_passes_native_lock_flag(tmp_path: Path, action_scripts: Path, mode: str, keep_uv_lock: bool) -> None: - source = Path(__file__).resolve().parents[2] - steps = yaml.safe_load((source / "refresh-release-lock/action.yml").read_text())["runs"]["steps"] - index = next( - index for index, step in enumerate(steps) if step.get("name") == "Resolve all project groups and extras" - ) - executable = tmp_path / "release-resolver/bin/uv" - executable.parent.mkdir(parents=True) - executable.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >"$RUNNER_TEMP/argv"\n') - executable.chmod(0o755) - - result = subprocess.run( - ["bash", "-e", "-o", "pipefail", str(action_scripts / f"refresh-release-lock-{index}.sh")], - cwd=tmp_path, - env=os.environ | {"MODE": mode, "KEEP_UV_LOCK": str(keep_uv_lock).lower(), "RUNNER_TEMP": str(tmp_path)}, - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode == 0, result.stderr - arguments = (tmp_path / "argv").read_text().splitlines() - assert ("--keep-uv-lock" in arguments) is keep_uv_lock - assert ("--check" in arguments) is (mode == "check") - - -@pytest.mark.parametrize("mode", ["check", "refresh"]) -def test_refresh_includes_native_uv_lock_in_diff(tmp_path: Path, action_scripts: Path, mode: str) -> None: - source = Path(__file__).resolve().parents[2] - steps = yaml.safe_load((source / "refresh-release-lock/action.yml").read_text())["runs"]["steps"] - index = next(index for index, step in enumerate(steps) if step.get("id") == "lock") - (tmp_path / "pyproject.toml").write_text('[project]\nname = "example"\nversion = "1.0"\n') - (tmp_path / "pylock.toml").write_text('lock-version = "1.0"\n') - (tmp_path / "uv.lock").write_text("version = 1\n") - run(tmp_path, "git", "init", "-b", "main") - run(tmp_path, "git", "config", "user.name", "Test") - run(tmp_path, "git", "config", "user.email", "test@example.org") - run(tmp_path, "git", "add", ".") - run(tmp_path, "git", "commit", "-m", "Add locks") - (tmp_path / "uv.lock").write_text("version = 2\n") - - result = subprocess.run( - ["bash", "-e", "-o", "pipefail", str(action_scripts / f"refresh-release-lock-{index}.sh")], - cwd=tmp_path, - env=os.environ - | { - "MODE": mode, - "KEEP_UV_LOCK": "true", - "ADDITIONAL_LOCK": "", - "RUNNER_TEMP": str(tmp_path), - "GITHUB_OUTPUT": str(tmp_path / "output"), - }, - text=True, - capture_output=True, - check=False, - ) - - assert (result.returncode != 0) is (mode == "check"), result.stderr - if mode == "refresh": - assert "diff --git a/uv.lock b/uv.lock" in (tmp_path / "lock.patch").read_text() - assert (tmp_path / "output").read_text() == "changed=true\n" diff --git a/tests/refresh_release_lock/test_refresh.py b/tests/refresh_release_lock/test_refresh.py new file mode 100644 index 0000000..b1b665d --- /dev/null +++ b/tests/refresh_release_lock/test_refresh.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any, Final + +import pytest +import yaml +from conftest import action, run + +_STEPS: Final = yaml.safe_load((Path(__file__).resolve().parents[2] / "refresh-release-lock/action.yml").read_text())[ + "runs" +]["steps"] +_LOCK: Final = ( + 'lock-version = "1.0"\ncreated-by = "nab"\nrequires-python = ">=3.15"\npackages = []\n' + 'environments = ["python_version == \\"3.15\\""]\n[tool.nab]\ncreated-at = 2026-10-01T00:00:00Z\n' +) + + +@pytest.fixture +def fake_uv(tmp_path: Path) -> Path: + executable = tmp_path / "bin/uv" + executable.parent.mkdir() + executable.write_text( + f'#!{sys.executable}\nimport json, os, pathlib, sys\npathlib.Path(os.environ["RUNNER_TEMP"], "uv-call.json")' + ".write_text(json.dumps({'argv': sys.argv[1:], 'env': dict(os.environ)}))\n" + ) + executable.chmod(0o755) + return executable + + +@pytest.fixture +def lock_project(tmp_path: Path) -> Path: + project = tmp_path / "project" + project.mkdir() + (project / "pyproject.toml").write_text( + '[project]\nname = "example"\nversion = "0.1.0"\nrequires-python = ">=3.15"\ndependencies = []\n' + '[dependency-groups]\nrelease = ["packaging"]\n' + '[tool.nab]\nuploaded-prior-to = "P7D"\nbase-group = "project-runtime"\n' + ) + run(project, "git", "init", "-b", "main") + run(project, "git", "config", "user.name", "Release test") + run(project, "git", "config", "user.email", "release@example.org") + return project + + +def test_refresh_rejects_secondary_options_without_lock(tmp_path: Path, action_scripts: Path) -> None: + result = action( + tmp_path, + tmp_path, + action_scripts, + "refresh-release-lock", + version="", + ADDITIONAL_LOCK_GROUPS="build", + ) + + assert result.returncode != 0 + assert "secondary matrix inputs require additional-lock" in result.stderr + + +@pytest.mark.parametrize(("mode", "upgrade"), [("check", []), ("refresh", ["--upgrade"])]) +def test_refresh_locks_every_group_and_extra_with_nab( + tmp_path: Path, action_scripts: Path, fake_uv: Path, mode: str, upgrade: list[str] +) -> None: + (tmp_path / "pylock.toml").write_text(_LOCK) + + result = _run_step(action_scripts, "Resolve all project groups and extras", tmp_path, fake_uv, MODE=mode) + + assert result.returncode == 0, result.stderr + call = _uv_call(tmp_path) + assert call["argv"] == [ + "run", + "--no-project", + "--python", + f"{tmp_path}/lock-tools", + "nab", + "lock", + "--all-groups", + "--all-extras", + "--no-cache", + *upgrade, + ] + + +@pytest.mark.parametrize("step", ["Resolve all project groups and extras", "Resolve optional Nab matrix"]) +def test_refresh_hides_certificate_overrides_from_nab( + tmp_path: Path, action_scripts: Path, fake_uv: Path, step: str +) -> None: + (tmp_path / "pylock.toml").write_text(_LOCK) + (tmp_path / "pylock.build.toml").write_text(_LOCK) + + result = _run_step( + action_scripts, + step, + tmp_path, + fake_uv, + MODE="check", + ADDITIONAL_LOCK="pylock.build.toml", + ADDITIONAL_GROUPS="", + ADDITIONAL_SELECTION="build-or-groups", + MATRIX_PYTHON=">=3.15", + MATRIX_PLATFORMS="linux_x86_64", + MATRIX_IMPLEMENTATIONS="cpython", + SSL_CERT_FILE=str(tmp_path / "ca.pem"), + SSL_CERT_DIR=str(tmp_path), + ) + + assert result.returncode == 0, result.stderr + assert not {"SSL_CERT_FILE", "SSL_CERT_DIR"} & _uv_call(tmp_path)["env"].keys() + + +def test_refresh_rejects_unknown_mode(tmp_path: Path, action_scripts: Path, fake_uv: Path) -> None: + result = _run_step(action_scripts, "Resolve all project groups and extras", tmp_path, fake_uv, MODE="other") + + assert result.returncode != 0 + assert "invalid lock mode: other" in result.stderr + + +def test_refresh_check_accepts_current_lock( + lock_project: Path, nab_executable: Path, tmp_path: Path, action_scripts: Path +) -> None: + _commit_lock(lock_project, nab_executable) + + result = action(lock_project, tmp_path, action_scripts, "refresh-release-lock", version="", MODE="check") + + assert result.returncode == 0, result.stderr + assert run(lock_project, "git", "status", "--porcelain").stdout == "" + + +def test_refresh_check_rejects_stale_lock( + lock_project: Path, nab_executable: Path, tmp_path: Path, action_scripts: Path +) -> None: + _commit_lock(lock_project, nab_executable) + project = lock_project / "pyproject.toml" + project.write_text(project.read_text().replace("dependencies = []", 'dependencies = ["six"]')) + + result = action(lock_project, tmp_path, action_scripts, "refresh-release-lock", version="", MODE="check") + + assert result.returncode != 0 + assert "regenerated Nab lock differs from the committed lock" in result.stderr + + +def test_refresh_writes_patch_for_stale_lock( + lock_project: Path, nab_executable: Path, tmp_path: Path, action_scripts: Path +) -> None: + _commit_lock(lock_project, nab_executable) + project = lock_project / "pyproject.toml" + project.write_text(project.read_text().replace("dependencies = []", 'dependencies = ["six"]')) + outputs: dict[str, str] = {} + + result = action(lock_project, tmp_path, action_scripts, "refresh-release-lock", outputs, version="", MODE="refresh") + + assert result.returncode == 0, result.stderr + assert outputs["changed"] == "true" + patch = (tmp_path / "lock.patch").read_text() + assert "diff --git a/pylock.toml b/pylock.toml" in patch + assert 'name = "six"' in patch + assert "diff --git a/pyproject.toml" not in patch + + +def _commit_lock(project: Path, nab: Path) -> None: + result = subprocess.run( + [str(nab), "lock", "--all-groups", "--all-extras", "--no-cache"], + cwd=project, + env={name: value for name, value in os.environ.items() if not name.startswith("SSL_CERT")}, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + run(project, "git", "add", ".") + run(project, "git", "commit", "-m", "Lock the project") + + +def _run_step( + action_scripts: Path, name: str, root: Path, fake_uv: Path, **environment: str +) -> subprocess.CompletedProcess[str]: + index = next(index for index, step in enumerate(_STEPS) if step.get("name") == name) + return subprocess.run( + ["bash", "-e", "-o", "pipefail", str(action_scripts / f"refresh-release-lock-{index}.sh")], + cwd=root, + env=os.environ + | { + "PATH": f"{fake_uv.parent}{os.pathsep}{os.environ['PATH']}", + "RUNNER_TEMP": str(root), + "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "refresh-release-lock"), + } + | environment, + text=True, + capture_output=True, + check=False, + ) + + +def _uv_call(root: Path) -> dict[str, Any]: + return json.loads((root / "uv-call.json").read_text()) diff --git a/tests/refresh_release_lock/test_resolver_upgrade.py b/tests/refresh_release_lock/test_resolver_upgrade.py deleted file mode 100644 index 3c8dfef..0000000 --- a/tests/refresh_release_lock/test_resolver_upgrade.py +++ /dev/null @@ -1,211 +0,0 @@ -from __future__ import annotations - -import os -import runpy -import subprocess -import sys -import tomllib -from pathlib import Path -from textwrap import dedent - -import pytest -import yaml - - -@pytest.mark.parametrize( - ("requirement", "versions", "mode", "include_group", "error"), - [ - pytest.param("uv>=0.13.5", ("0.13.5",), "widen", True, "exact uv pin", id="non-exact-start"), - pytest.param('uv==0.13.5", "other', ("0.13.5",), "widen", True, "one uv requirement", id="extra-requirement"), - pytest.param("uv==0.13.5", ("0.13.5",), "pin", True, "temporary uv range", id="pin-without-widen"), - pytest.param("uv>=0.13.5", ("0.13.5",), "pin", False, "omits release-resolver", id="missing-group"), - pytest.param("uv>=0.13.5", ("0.13.5", "0.13.6"), "pin", True, "one uv resolver", id="ambiguous-candidate"), - pytest.param("uv>=0.13.5", (), "pin", True, "one uv resolver", id="missing-candidate"), - ], -) -def test_resolver_upgrade_rejects_invalid_state( - tmp_path: Path, requirement: str, versions: tuple[str, ...], mode: str, include_group: bool, error: str -) -> None: - source = Path(__file__).resolve().parents[2] / "tools/refresh_uv_resolver.py" - project = tmp_path / "pyproject.toml" - project.write_text(f'[dependency-groups]\nrelease-resolver = ["{requirement}"]\n') - lock = tmp_path / "pylock.toml" - lock.write_text( - ('dependency-groups = ["release-resolver"]\n' if include_group else "dependency-groups = []\n") - + ("".join(_uv_package(version) for version in versions) if versions else "packages = []\n") - ) - - result = subprocess.run( - [sys.executable, str(source), mode, str(project), str(lock)], - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode != 0 - assert error in result.stderr - - -def test_resolver_widen_starts_at_the_current_pin(tmp_path: Path) -> None: - project = tmp_path / "pyproject.toml" - project.write_text('[dependency-groups]\nrelease-resolver = ["uv==0.13.6"]\n') - lock = tmp_path / "pylock.toml" - lock.write_text('dependency-groups = ["release-resolver"]\n' + _uv_package("0.13.6")) - - result = _resolver(tmp_path, "widen") - - assert result.returncode == 0, result.stderr - assert tomllib.loads(project.read_text())["dependency-groups"]["release-resolver"] == ["uv>=0.13.6"] - - -def test_resolver_pin_ignores_uv_from_other_groups(tmp_path: Path) -> None: - project = tmp_path / "pyproject.toml" - project.write_text('[dependency-groups]\nrelease-resolver = ["uv>=0.13.5"]\n') - lock = tmp_path / "pylock.toml" - lock.write_text( - 'dependency-groups = ["release-resolver", "test"]\n' - + _uv_package("0.13.6") - + "[[packages]]\nname = 'uv'\nversion = '0.14.0'\nmarker = '\"test\" in dependency_groups'\n" - ) - - result = _resolver(tmp_path, "pin") - - assert result.returncode == 0, result.stderr - assert tomllib.loads(project.read_text())["dependency-groups"]["release-resolver"] == ["uv==0.13.6"] - - -def _resolver(root: Path, mode: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [ - sys.executable, - str(Path(__file__).resolve().parents[2] / "tools/refresh_uv_resolver.py"), - mode, - str(root / "pyproject.toml"), - str(root / "pylock.toml"), - ], - text=True, - capture_output=True, - check=False, - ) - - -@pytest.mark.parametrize( - ("repository", "pin", "locked", "accepted"), - [ - pytest.param("tox-dev/github-workflows", "uv==0.13.6", "0.13.6", True, id="shared-resolver"), - pytest.param("tox-dev/github-workflows", "uv==0.13.6", "0.13.5", False, id="lock-mismatch"), - pytest.param("tox-dev/github-workflows", "uv>=0.13.6", "0.13.6", False, id="range"), - pytest.param("tox-dev/other", "uv==0.13.6", "0.13.6", False, id="other-repository"), - ], -) -def test_writer_accepts_only_the_locked_resolver_pin( - tmp_path: Path, repository: str, pin: str, locked: str, accepted: bool -) -> None: - project = tmp_path / "pyproject.toml" - project.write_text('[dependency-groups]\nrelease-resolver = ["uv==0.13.5"]\n[tool.release-creator.locks]\n') - subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) - subprocess.run(["git", "add", "pyproject.toml"], cwd=tmp_path, check=True) - subprocess.run( - ["git", "-c", "user.name=Test", "-c", "user.email=test@example.org", "commit", "-qm", "Pin"], - cwd=tmp_path, - check=True, - ) - project.write_text(project.read_text().replace("uv==0.13.5", pin)) - (tmp_path / "pylock.toml").write_text('dependency-groups = ["release-resolver"]\n' + _uv_package(locked)) - - result = subprocess.run( - [sys.executable, str(Path(__file__).resolve().parents[2] / "tools/verify_cutoff_patch.py")], - cwd=tmp_path, - env=os.environ | {"GITHUB_REPOSITORY": repository}, - text=True, - capture_output=True, - check=False, - ) - - assert (result.returncode == 0) is accepted, result.stderr - - -def test_resolver_upgrade_import_has_no_side_effect() -> None: - source = Path(__file__).resolve().parents[2] / "tools/refresh_uv_resolver.py" - - assert runpy.run_path(str(source), run_name="imported")["__all__"] == ["main"] - - -def test_resolver_upgrade_uses_candidate_to_regenerate_lock(tmp_path: Path, action_scripts: Path) -> None: - source = Path(__file__).resolve().parents[2] - steps = yaml.safe_load((source / "refresh-release-lock/action.yml").read_text())["runs"]["steps"] - names = [step.get("name", "") for step in steps] - old_index = names.index("Resolve all project groups and extras") - new_index = names.index("Resolve with the updated uv resolver") - assert names.index("Widen the shared uv pin") < old_index - assert old_index < names.index("Pin the updated uv resolver") < new_index - installer = steps[new_index - 1] - assert installer["uses"] == "$/install-locked-tools" - assert installer["with"]["groups"] == "release-resolver" - assert installer["with"].get("lock-source", "caller") == "caller" - - project = tmp_path / "pyproject.toml" - project.write_text('[dependency-groups]\nrelease-resolver = ["uv==0.13.5"]\n') - (tmp_path / "pylock.toml").write_text('dependency-groups = ["release-resolver"]\n' + _uv_package("0.13.5")) - (tmp_path / "candidate.toml").write_text( - 'lock-version = "old-output"\ndependency-groups = ["release-resolver"]\n' + _uv_package("0.13.6") - ) - (tmp_path / "final.toml").write_text( - 'lock-version = "new-output"\ndependency-groups = ["release-resolver"]\n' + _uv_package("0.13.6") - ) - _fake_uv(tmp_path / "release-resolver/bin/uv", "candidate.toml") - _fake_uv(tmp_path / "updated-release-resolver/bin/uv", "final.toml") - tool = source / "tools/refresh_uv_resolver.py" - for mode in ("widen", "pin"): - if mode == "pin": - _run_step(action_scripts, old_index, tmp_path) - result = subprocess.run( - [sys.executable, str(tool), mode, str(project), str(tmp_path / "pylock.toml")], - text=True, - capture_output=True, - check=False, - ) - assert result.returncode == 0, result.stderr - _run_step(action_scripts, new_index, tmp_path) - - assert tomllib.loads(project.read_text())["dependency-groups"]["release-resolver"] == ["uv==0.13.6"] - assert tomllib.loads((tmp_path / "pylock.toml").read_text())["lock-version"] == "new-output" - assert (tmp_path / "checked-with-candidate").exists() - - -def _uv_package(version: str) -> str: - return f"[[packages]]\nname = 'uv'\nversion = '{version}'\nmarker = '\"release-resolver\" in dependency_groups'\n" - - -def _fake_uv(path: Path, lock: str) -> None: - path.parent.mkdir(parents=True) - path.write_text( - dedent( - f"""\ - #!{sys.executable} - import pathlib - import shutil - import sys - - root = pathlib.Path.cwd() - if "--check" in sys.argv: - assert (root / "pylock.toml").read_bytes() == (root / "{lock}").read_bytes() - (root / "checked-with-candidate").touch() - else: - shutil.copyfile(root / "{lock}", root / "pylock.toml") - """ - ) - ) - path.chmod(0o755) - - -def _run_step(action_scripts: Path, index: int, root: Path) -> None: - result = subprocess.run( - ["bash", "-e", "-o", "pipefail", str(action_scripts / f"refresh-release-lock-{index}.sh")], - cwd=root, - env=os.environ | {"MODE": "refresh", "KEEP_UV_LOCK": "false", "RUNNER_TEMP": str(root)}, - text=True, - capture_output=True, - check=False, - ) - assert result.returncode == 0, result.stderr diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py index d84462f..575487f 100644 --- a/tests/upgrade_release_tools/test_upgrade.py +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -2,12 +2,11 @@ import os import subprocess -import tomllib from pathlib import Path import pytest import yaml -from conftest import action, run +from conftest import run from pytest_httpserver import HTTPServer @@ -48,41 +47,11 @@ def lock_repository(tmp_path: Path) -> Path: return repository -def test_upgrade_preserves_deliberate_nab_hold(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: - project = lock_repository / "pyproject.toml" - project.write_text( - project.read_text() - + '[[tool.nab.package-rules]]\nmatch = ["nab"]\nuploaded-prior-to = "2026-01-01T00:00:00Z"\n', - ) - installed = action( - lock_repository, - tmp_path, - upgrade_scripts, - "install-locked-tools", - version="", - GROUPS="lock-tools", - LOCK_SOURCE="shared", - VENV=str(tmp_path / "lock-tools"), - ) - assert installed.returncode == 0, installed.stderr - expired = execute(lock_repository, refresh_script(upgrade_scripts, "Expire reviewed package cutoffs"), tmp_path) - assert expired.returncode == 0, expired.stderr - assert tomllib.loads(project.read_text())["tool"]["nab"]["package-rules"][0]["uploaded-prior-to"] == ( - "2026-01-01T00:00:00Z" - ) - - -@pytest.mark.parametrize( - "changed_path", - [pytest.param("pylock.toml", id="lock"), pytest.param("pyproject.toml", id="expired-floor")], -) -def test_upgrade_emits_patch_for_allowed_change( - lock_repository: Path, tmp_path: Path, upgrade_scripts: Path, changed_path: str -) -> None: +def test_upgrade_emits_patch_for_lock_change(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: (lock_repository / "pylock.toml").write_text('lock-version = "1.0"\n') run(lock_repository, "git", "add", ".") run(lock_repository, "git", "commit", "-m", "Add lock") - (lock_repository / changed_path).write_text((lock_repository / changed_path).read_text() + "\n") + (lock_repository / "pylock.toml").write_text((lock_repository / "pylock.toml").read_text() + "\n") result = execute( lock_repository, refresh_script(upgrade_scripts, "lock"), @@ -92,19 +61,18 @@ def test_upgrade_emits_patch_for_allowed_change( ) assert result.returncode == 0, result.stderr assert (tmp_path / "output").read_text() == "changed=true\n" - assert f"diff --git a/{changed_path} b/{changed_path}" in (tmp_path / "lock.patch").read_text() + assert "diff --git a/pylock.toml b/pylock.toml" in (tmp_path / "lock.patch").read_text() @pytest.mark.parametrize( - ("path", "additional_lock", "keep_uv_lock", "accepted"), + ("path", "additional_lock", "accepted"), [ - pytest.param("pylock.toml", "", False, True, id="lock"), - pytest.param("pyproject.toml", "", False, True, id="nab-policy"), - pytest.param("pylock.zipapp.toml", "pylock.zipapp.toml", False, True, id="secondary-lock"), - pytest.param(".pre-commit-config.yaml", "", False, False, id="hook-config"), - pytest.param("uv.lock", "", False, False, id="uv-lock-disabled"), - pytest.param("uv.lock", "", True, True, id="uv-lock-enabled"), - pytest.param("other.txt", "", False, False, id="unrelated-file"), + pytest.param("pylock.toml", "", True, id="lock"), + pytest.param("pyproject.toml", "", False, id="project"), + pytest.param("pylock.zipapp.toml", "pylock.zipapp.toml", True, id="secondary-lock"), + pytest.param(".pre-commit-config.yaml", "", False, id="hook-config"), + pytest.param("uv.lock", "", False, id="uv-lock"), + pytest.param("other.txt", "", False, id="unrelated-file"), ], ) def test_upgrade_checks_lock_patch_path( @@ -113,7 +81,6 @@ def test_upgrade_checks_lock_patch_path( publish_scripts: Path, path: str, additional_lock: str, - keep_uv_lock: bool, accepted: bool, ) -> None: (lock_repository / path).write_text("before\n") @@ -130,7 +97,6 @@ def test_upgrade_checks_lock_patch_path( publish_scripts / "publish-check-artifact-patch-paths.sh", tmp_path, ADDITIONAL_LOCK=additional_lock, - KEEP_UV_LOCK=str(keep_uv_lock).lower(), ) assert (result.returncode == 0) == accepted assert (f"unexpected lock patch path: {path}" in result.stderr) == (not accepted) @@ -247,7 +213,6 @@ def execute(repository: Path, script: Path, tmp_path: Path, **environment: str) "MATRIX_PYTHON": "", "MATRIX_PLATFORMS": "", "MATRIX_IMPLEMENTATIONS": "", - "KEEP_UV_LOCK": "false", } | environment, text=True, diff --git a/tests/validate_news/test_news.py b/tests/validate_news/test_news.py index 64d3b3e..e62f4f4 100644 --- a/tests/validate_news/test_news.py +++ b/tests/validate_news/test_news.py @@ -130,18 +130,17 @@ def test_validate_news_requires_pr_base(repository: Path, tmp_path: Path, action @pytest.mark.parametrize( - ("additional_lock", "keep_uv_lock", "changed_path", "changed"), + ("additional_lock", "changed_path", "changed"), [ - pytest.param("pylock.zipapp.toml", False, "pylock.zipapp.toml", "true", id="selected-secondary-lock"), - pytest.param("", False, "pylock.zipapp.toml", "false", id="unselected-secondary-lock"), - pytest.param("", False, "pylock.toml", "true", id="primary-lock"), - pytest.param("", False, "pyproject.toml", "true", id="root-project"), - pytest.param("", False, ".github/workflows/release.yaml", "true", id="workflow-repin"), - pytest.param("", False, "README.md", "false", id="unrelated"), - pytest.param("", False, "nab.toml", "true", id="nab-policy"), - pytest.param("", False, "meta/pyproject.toml", "true", id="workspace-member"), - pytest.param("", True, "uv.lock", "true", id="selected-native-uv-lock"), - pytest.param("", False, "uv.lock", "false", id="unselected-native-uv-lock"), + pytest.param("pylock.zipapp.toml", "pylock.zipapp.toml", "true", id="selected-secondary-lock"), + pytest.param("", "pylock.zipapp.toml", "false", id="unselected-secondary-lock"), + pytest.param("", "pylock.toml", "true", id="primary-lock"), + pytest.param("", "pyproject.toml", "true", id="root-project"), + pytest.param("", ".github/workflows/release.yaml", "true", id="workflow-repin"), + pytest.param("", "README.md", "false", id="unrelated"), + pytest.param("", "nab.toml", "true", id="nab-policy"), + pytest.param("", "meta/pyproject.toml", "true", id="workspace-member"), + pytest.param("", "uv.lock", "false", id="native-uv-lock"), ], ) def test_validate_news_detects_release_input_change( @@ -149,7 +148,6 @@ def test_validate_news_detects_release_input_change( tmp_path: Path, action_scripts: Path, additional_lock: str, - keep_uv_lock: bool, changed_path: str, changed: str, ) -> None: @@ -184,7 +182,6 @@ def test_validate_news_detects_release_input_change( | { "BASE_REF": "main", "ADDITIONAL_LOCK": additional_lock, - "KEEP_UV_LOCK": str(keep_uv_lock).lower(), "GITHUB_OUTPUT": str(output), "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "validate-news"), }, @@ -213,7 +210,6 @@ def test_validate_news_rejects_removed_release_lock(repository: Path, tmp_path: | { "BASE_REF": "main", "ADDITIONAL_LOCK": "", - "KEEP_UV_LOCK": "false", "GITHUB_OUTPUT": str(tmp_path / "lock-change-output"), "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "validate-news"), }, diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index a2c7bd4..b31001e 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -4,29 +4,30 @@ import argparse import json +import re import ssl import time import tomllib from collections import defaultdict -from collections.abc import Iterable, Iterator +from collections.abc import Iterable, Iterator, Mapping from concurrent.futures import ThreadPoolExecutor from datetime import UTC, datetime, timedelta from http.client import IncompleteRead from pathlib import Path -from typing import cast +from typing import Final, cast from urllib.error import HTTPError, URLError from urllib.parse import urlsplit from urllib.request import Request, urlopen import certifi -from lock_groups import lock_provider, validate_workspace_sources -from nab_cutoffs import reviewed_cutoff, validate_indexes from packaging._parser import Variable from packaging.markers import Marker from packaging.pylock import Package, Pylock, PylockValidationError from packaging.utils import canonicalize_name from packaging.version import InvalidVersion, Version +_DURATION: Final = re.compile(r"P(?P[0-9]+)D") + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) @@ -48,13 +49,13 @@ def main() -> None: name for name in {*groups, *(lock.dependency_groups or ())} if canonicalize_name(name) != name ): raise ValueError(f"dependency group names must be normalized: {', '.join(unnormalized)}") + nab: dict = {} if args.project: if (args.project.parent / "nab.toml").exists(): raise ValueError("project nab.toml can override the reviewed index policy") with args.project.open("rb") as stream: - project = tomllib.load(stream) - if "nab" in project.get("tool", {}): - validate_indexes(project["tool"]["nab"]) + nab = tomllib.load(stream).get("tool", {}).get("nab", {}) + _validate_indexes(nab) if lock_provider(lock.tool) == "release-creator": validate_workspace_sources(args.project) if (missing := groups - set(lock.dependency_groups or ())) and not ( @@ -63,7 +64,7 @@ def main() -> None: parser.error(f"missing lock dependency groups: {', '.join(sorted(missing))}") if args.build_constraints and "build" not in (lock.dependency_groups or ()): groups.clear() - _check_cooling(lock, groups, local_tool_source=args.local_tool_source) + _check_cooling(lock, groups, nab, local_tool_source=args.local_tool_source) if args.verify_pypi: _verify_pypi(lock, groups, local_tool_source=args.local_tool_source) if args.shared_lock: @@ -78,7 +79,61 @@ def main() -> None: parser.error(str(error)) -def _check_cooling(lock: Pylock, groups: set[str], *, local_tool_source: bool = False) -> None: +def lock_provider(tool: Mapping[str, object] | None) -> str: + if "release-creator" in (tool or {}): + return "release-creator" + if "nab" in (tool or {}): + return "nab" + raise ValueError("lock has no recognized producer") + + +def validate_workspace_sources(project: Path) -> None: + with project.open("rb") as stream: + root = tomllib.load(stream) + workspace = root.get("tool", {}).get("uv", {}).get("workspace", {}) + members = workspace.get("members", []) + excluded = workspace.get("exclude", []) + if not all( + isinstance(patterns, list) and all(isinstance(pattern, str) for pattern in patterns) + for patterns in (members, excluded) + ): + raise ValueError("workspace patterns must be strings") + directory = project.parent.resolve() + + def matched(patterns: list[str]) -> set[Path]: + paths = {path.resolve() for pattern in patterns for path in directory.glob(pattern)} + if any(not path.is_relative_to(directory) for path in paths): + raise ValueError("workspace members must be inside the project") + return paths + + projects = [root] + for member in sorted(matched(members) - matched(excluded)): + if member.is_dir(): + with (member / "pyproject.toml").open("rb") as stream: + projects.append(tomllib.load(stream)) + names = { + _normalized_name(name) for item in projects if isinstance(name := item.get("project", {}).get("name"), str) + } + for item in projects: + for name, source in item.get("tool", {}).get("uv", {}).get("sources", {}).items(): + if source != {"workspace": True} or _normalized_name(name) not in names: + raise ValueError(f"uv source must name a workspace project: {name}") + + +def _normalized_name(name: str) -> str: + return "-".join(filter(None, name.casefold().replace("_", "-").replace(".", "-").split("-"))) + + +def _validate_indexes(nab: dict) -> None: + if any(index != {"name": "pypi", "url": "https://pypi.org/simple/"} for index in nab.get("indexes", [])): + raise ValueError("custom Nab indexes are not approved for release locks") + if nab.get("index") or any("index" in settings for settings in nab.get("packages", {}).values()): + raise ValueError("custom Nab indexes are not approved for release locks") + if any("index" in settings for settings in nab.get("package-rules", [])): + raise ValueError("custom Nab indexes are not approved for release locks") + + +def _check_cooling(lock: Pylock, groups: set[str], nab: dict, *, local_tool_source: bool = False) -> None: producer = (lock.tool or {}).get(lock_provider(lock.tool)) if not isinstance(producer, dict): raise TypeError("lock producer settings must be a table") @@ -86,6 +141,7 @@ def _check_cooling(lock: Pylock, groups: set[str], *, local_tool_source: bool = if not isinstance(created, datetime) or created.tzinfo is None or created > datetime.now(tz=UTC): raise ValueError("lock has no valid creation time or was created in the future") threshold = created - timedelta(days=7) + cutoffs = _package_cutoffs(nab, created) whole_seconds = lock.created_by == "release-creator (uv)" and lock_provider(lock.tool) == "release-creator" for package in _selected_packages(lock, groups, local_tool_source=local_tool_source): if package.archive is not None or package.vcs is not None or package.directory is not None: @@ -95,7 +151,7 @@ def _check_cooling(lock: Pylock, groups: set[str], *, local_tool_source: bool = if package.index != "https://pypi.org/simple/": raise ValueError(f"release lock has an unapproved index: {package.name}: {package.index}") artifacts = ([package.sdist] if package.sdist is not None else []) + list(package.wheels or ()) - cutoff = max(threshold, reviewed_cutoff(package.name, str(package.version)) or threshold) + cutoff = max(threshold, cutoffs.get(canonicalize_name(package.name), threshold)) for artifact in artifacts: url = urlsplit(artifact.url or "") if artifact.path is not None or url.scheme != "https" or url.netloc != "files.pythonhosted.org": @@ -108,6 +164,41 @@ def _check_cooling(lock: Pylock, groups: set[str], *, local_tool_source: bool = raise ValueError(f"release lock has an uncooled artifact: {artifact.name}") +def _package_cutoffs(nab: dict, created: datetime) -> dict[str, datetime]: + overrides = [ + (name, settings["uploaded-prior-to"]) + for name, settings in nab.get("packages", {}).items() + if "uploaded-prior-to" in settings + ] + overrides.extend( + (name, rule["uploaded-prior-to"]) + for rule in nab.get("package-rules", []) + if "uploaded-prior-to" in rule + for name in rule["match"] + ) + cutoffs: dict[str, datetime] = {} + for name, value in overrides: + cutoff = _cutoff(name, value, created) + key = canonicalize_name(name) + cutoffs[key] = max(cutoff, cutoffs.get(key, cutoff)) + return cutoffs + + +def _cutoff(name: str, value: object, created: datetime) -> datetime: + if value is False: + return created + if isinstance(value, str) and (match := _DURATION.fullmatch(value)) is not None: + return created - timedelta(days=int(match["days"])) + if isinstance(value, str): + try: + value = datetime.fromisoformat(value) + except ValueError as error: + raise ValueError(f"invalid Nab cutoff for {name}: {value}") from error + if not isinstance(value, datetime) or value.tzinfo is None: + raise ValueError(f"invalid Nab cutoff for {name}: {value}") + return value + + def _verify_pypi(lock: Pylock, groups: set[str], *, local_tool_source: bool) -> None: expected: dict[str, list[tuple[str, str | None, str, datetime | None]]] = defaultdict(list) for package in _selected_packages(lock, groups, local_tool_source=local_tool_source): @@ -252,5 +343,7 @@ def _selected_release_creators(lock: Pylock, groups: set[str]) -> Iterator[Versi __all__ = [ + "lock_provider", "main", + "validate_workspace_sources", ] diff --git a/tools/check_recent_cutoffs.py b/tools/check_recent_cutoffs.py deleted file mode 100644 index 6da4ee1..0000000 --- a/tools/check_recent_cutoffs.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Reject recent package cutoffs without an explicit reviewed exception.""" - -from __future__ import annotations - -import argparse -import tomllib -from datetime import UTC, datetime, timedelta -from pathlib import Path - -from nab_cutoffs import validate_recent_cutoffs, validate_release_creator_cutoffs - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("project", type=Path) - args = parser.parse_args() - try: - if (args.project.parent / "nab.toml").exists(): - parser.error("project nab.toml can override the reviewed cutoff policy") - project = args.project.read_text() - threshold = datetime.now(UTC) - timedelta(days=7) - tool = tomllib.loads(project)["tool"] - if "nab" in tool: - validate_recent_cutoffs(project, threshold) - if "release-creator" in tool: - validate_release_creator_cutoffs(project, threshold) - if "nab" not in tool and "release-creator" not in tool: - raise ValueError("project has no lock cutoff policy") - except (OSError, KeyError, TypeError, ValueError) as error: - parser.error(str(error)) - - -if __name__ == "__main__": - main() - - -__all__ = [ - "main", -] diff --git a/tools/expire_cutoffs.py b/tools/expire_cutoffs.py deleted file mode 100644 index d6e30cb..0000000 --- a/tools/expire_cutoffs.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Remove reviewed package cutoffs once the global seven-day window covers them.""" - -from __future__ import annotations - -import argparse -import tomllib -from datetime import UTC, datetime, timedelta -from pathlib import Path - -from nab_cutoffs import remove_expired_cutoffs, remove_expired_release_creator_cutoffs -from tomlkit.exceptions import ParseError - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("project", type=Path) - args = parser.parse_args() - try: - source = args.project.read_text() - threshold = datetime.now(UTC) - timedelta(days=7) - tool = tomllib.loads(source)["tool"] - updated = remove_expired_cutoffs(source, threshold) if "nab" in tool else source - if "release-creator" in tool: - updated = remove_expired_release_creator_cutoffs(updated, threshold) - if source != updated: - args.project.write_text(updated) - except (OSError, KeyError, TypeError, ValueError, ParseError) as error: - parser.error(str(error)) - - -if __name__ == "__main__": - main() - - -__all__ = [ - "main", -] diff --git a/tools/lock_groups.py b/tools/lock_groups.py deleted file mode 100644 index 20c97fa..0000000 --- a/tools/lock_groups.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Require a project lock produced by release-creator and check its workspace sources.""" - -from __future__ import annotations - -import argparse -import tomllib -from collections.abc import Mapping -from pathlib import Path - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("lock", type=Path) - args = parser.parse_args() - try: - with args.lock.open("rb") as stream: - provider = lock_provider(tomllib.load(stream).get("tool")) - except (OSError, TypeError, ValueError) as error: - parser.error(f"cannot read the lock producer: {error}") - if provider != "release-creator": - parser.error(f"{args.lock} must come from release-creator locks; relock it") - - -def lock_provider(tool: Mapping[str, object] | None) -> str: - if "release-creator" in (tool or {}): - return "release-creator" - if "nab" in (tool or {}): - return "nab" - raise ValueError("lock has no recognized producer") - - -def validate_workspace_sources(project: Path) -> None: - with project.open("rb") as stream: - root = tomllib.load(stream) - workspace = root.get("tool", {}).get("uv", {}).get("workspace", {}) - members = workspace.get("members", []) - excluded = workspace.get("exclude", []) - if not all( - isinstance(patterns, list) and all(isinstance(pattern, str) for pattern in patterns) - for patterns in (members, excluded) - ): - raise ValueError("workspace patterns must be strings") - directory = project.parent.resolve() - - def matched(patterns: list[str]) -> set[Path]: - paths = {path.resolve() for pattern in patterns for path in directory.glob(pattern)} - if any(not path.is_relative_to(directory) for path in paths): - raise ValueError("workspace members must be inside the project") - return paths - - projects = [root] - for member in sorted(matched(members) - matched(excluded)): - if member.is_dir(): - with (member / "pyproject.toml").open("rb") as stream: - projects.append(tomllib.load(stream)) - names = { - _normalized_name(name) for item in projects if isinstance(name := item.get("project", {}).get("name"), str) - } - for item in projects: - for name, source in item.get("tool", {}).get("uv", {}).get("sources", {}).items(): - if source != {"workspace": True} or _normalized_name(name) not in names: - raise ValueError(f"uv source must name a workspace project: {name}") - - -def _normalized_name(name: str) -> str: - return "-".join(filter(None, name.casefold().replace("_", "-").replace(".", "-").split("-"))) - - -if __name__ == "__main__": - main() - - -__all__ = [ - "lock_provider", - "main", - "validate_workspace_sources", -] diff --git a/tools/nab_cutoffs.py b/tools/nab_cutoffs.py deleted file mode 100644 index 6ca548e..0000000 --- a/tools/nab_cutoffs.py +++ /dev/null @@ -1,211 +0,0 @@ -from __future__ import annotations - -import re -import tomllib -from datetime import UTC, datetime -from typing import Final - -_DURATION: Final = re.compile( - r""" - ^P # ISO duration prefix - (?P[0-9]+) # Nab supports whole days - D$ # Day unit and end - """, - re.VERBOSE, -) - -_REVIEWED_VERSIONS: Final = { - **{ - (name, "0.0.22"): datetime(2026, 10, 18, 5, 45, 9, tzinfo=UTC) - for name in ("nab", "nab-index", "nab-markersets", "nab-project", "nab-provider", "nab-resolver") - }, - ("release-creator", "0.6.5"): datetime(2026, 10, 23, 19, 54, 50, tzinfo=UTC), - ("release-creator", "0.6.6"): datetime(2026, 10, 23, 20, 48, 24, tzinfo=UTC), - ("release-creator", "0.6.7"): datetime(2026, 10, 23, 21, 40, 6, tzinfo=UTC), - ("release-creator", "1.0.0"): datetime(2026, 10, 24, 6, 7, 46, tzinfo=UTC), - ("release-creator", "1.1.0"): datetime(2026, 10, 24, 9, 8, 40, tzinfo=UTC), - ("release-creator", "2.0.0"): datetime(2026, 10, 25, 7, 8, 32, tzinfo=UTC), - ("uv", "0.13.5"): datetime(2026, 10, 22, 1, 32, 34, tzinfo=UTC), - ("uv-build", "0.13.4"): datetime(2026, 10, 21, tzinfo=UTC), -} -_REVIEWED_EXCEPTIONS: Final = { - name: min(reviewed for (package, _), reviewed in _REVIEWED_VERSIONS.items() if package == name) - for name, _ in _REVIEWED_VERSIONS -} - - -def reviewed_cutoff(name: str, version: str) -> datetime | None: - return _REVIEWED_VERSIONS.get((name, version)) - - -def validate_recent_cutoffs(project: str, threshold: datetime) -> None: - nab = tomllib.loads(project)["tool"]["nab"] - global_cutoff = nab.get("uploaded-prior-to") - if not isinstance(global_cutoff, str) or (match := _DURATION.fullmatch(global_cutoff)) is None: - raise ValueError("global Nab cutoff must be a duration of at least P7D") - if int(match["days"]) < 7: - raise ValueError("global Nab cutoff must be a duration of at least P7D") - candidates = [ - (name, settings["uploaded-prior-to"]) - for name, settings in nab.get("packages", {}).items() - if "uploaded-prior-to" in settings - ] - candidates.extend( - (name, rule["uploaded-prior-to"]) - for rule in nab.get("package-rules", []) - if "uploaded-prior-to" in rule - for name in rule["match"] - ) - validate_indexes(nab) - for name, value in candidates: - if value is False: - raise ValueError(f"Nab cutoff for {name} weakens the seven-day policy: {value}") - if isinstance(value, str) and (match := _DURATION.fullmatch(value)) is not None: - if int(match["days"]) < 7: - raise ValueError(f"Nab cutoff for {name} weakens the seven-day policy: {value}") - continue - timestamp = _timestamp(name, value) - if timestamp > threshold and timestamp not in { - reviewed for (package, _), reviewed in _REVIEWED_VERSIONS.items() if package == name - }: - raise ValueError(f"unreviewed recent Nab cutoff for {name}: {timestamp.isoformat()}") - - -def validate_release_creator_cutoffs(project: str, threshold: datetime) -> None: - config = tomllib.loads(project)["tool"]["release-creator"].get("locks", {}) - if set(config) - {"exclude-newer-package"}: - raise ValueError("release-creator lock policy must contain only exclude-newer-package") - overrides = config.get("exclude-newer-package", {}) - if not isinstance(overrides, dict): - raise TypeError("release-creator package cutoffs must be a table") - for name, value in overrides.items(): - if not isinstance(value, str): - raise TypeError(f"invalid release-creator cutoff for {name}: {value}") - timestamp = _timestamp(name, value) - if timestamp > threshold and timestamp not in { - reviewed for (package, _), reviewed in _REVIEWED_VERSIONS.items() if package == name - }: - raise ValueError(f"unreviewed recent release-creator cutoff for {name}: {timestamp.isoformat()}") - - -def validate_indexes(nab: dict) -> None: - if any(index != {"name": "pypi", "url": "https://pypi.org/simple/"} for index in nab.get("indexes", [])): - raise ValueError("custom Nab indexes are not approved for release locks") - if nab.get("index") or any("index" in settings for settings in nab.get("packages", {}).values()): - raise ValueError("custom Nab indexes are not approved for release locks") - if any("index" in settings for settings in nab.get("package-rules", [])): - raise ValueError("custom Nab indexes are not approved for release locks") - - -def remove_expired_cutoffs(project: str, threshold: datetime) -> str: - import tomlkit - - names, positions = expired_cutoffs(project, threshold) - if not names and not positions: - return project - document = tomlkit.parse(project) - remove_cutoffs(document["tool"]["nab"], names, positions) - return tomlkit.dumps(document) - - -def remove_expired_release_creator_cutoffs(project: str, threshold: datetime) -> str: - import tomlkit - - names = expired_release_creator_cutoffs(project, threshold) - if not names: - return project - document = tomlkit.parse(project) - remove_release_creator_cutoffs(document["tool"]["release-creator"]["locks"], names) - return tomlkit.dumps(document) - - -def remove_release_creator_cutoffs(locks: dict, names: set[str]) -> None: - for name in names: - del locks["exclude-newer-package"][name] - if not locks["exclude-newer-package"]: - del locks["exclude-newer-package"] - - -def expired_release_creator_cutoffs(project: str, threshold: datetime) -> set[str]: - overrides = tomllib.loads(project)["tool"]["release-creator"].get("locks", {}).get("exclude-newer-package", {}) - return { - name - for name, value in overrides.items() - if name in _REVIEWED_EXCEPTIONS - and _timestamp(name, value) - in {reviewed for (package, _), reviewed in _REVIEWED_VERSIONS.items() if package == name} - and _timestamp(name, value) <= threshold - } - - -def remove_cutoffs(nab: dict, names: set[str], positions: set[int]) -> None: - for name in names: - settings = nab["packages"][name] - del settings["uploaded-prior-to"] - if not settings: - del nab["packages"][name] - if "packages" in nab and not nab["packages"]: - del nab["packages"] - for index in sorted(positions, reverse=True): - rule = nab["package-rules"][index] - del rule["uploaded-prior-to"] - if set(rule) == {"match"}: - del nab["package-rules"][index] - if "package-rules" in nab and not nab["package-rules"]: - del nab["package-rules"] - - -def expired_cutoffs(project: str, threshold: datetime) -> tuple[set[str], set[int]]: - nab = tomllib.loads(project)["tool"]["nab"] - packages = { - str(name) - for name, settings in nab.get("packages", {}).items() - if name in _REVIEWED_EXCEPTIONS - and (value := settings.get("uploaded-prior-to")) is not None - and _expired( - value, threshold, {reviewed for (package, _), reviewed in _REVIEWED_VERSIONS.items() if package == name} - ) - } - rules = { - index - for index, rule in enumerate(nab.get("package-rules", [])) - if set(rule["match"]) <= _REVIEWED_EXCEPTIONS.keys() - and (value := rule.get("uploaded-prior-to")) is not None - and all( - _expired( - value, threshold, {reviewed for (package, _), reviewed in _REVIEWED_VERSIONS.items() if package == name} - ) - for name in rule["match"] - ) - } - return packages, rules - - -def _expired(value: str | datetime | bool, threshold: datetime, reviewed: set[datetime]) -> bool: - if value is False or isinstance(value, str) and _DURATION.fullmatch(value) is not None: - return False - timestamp = _timestamp("Nab", value) - return timestamp in reviewed and timestamp <= threshold - - -def _timestamp(name: str, value: str | datetime | bool) -> datetime: - if not isinstance(value, str | datetime) or isinstance(value, bool): - raise TypeError(f"invalid cutoff for {name}: {value}") - timestamp = datetime.fromisoformat(value) if isinstance(value, str) else value - if timestamp.tzinfo is None: - raise ValueError(f"cutoff for {name} has no timezone: {value}") - return timestamp - - -__all__ = [ - "expired_cutoffs", - "expired_release_creator_cutoffs", - "remove_cutoffs", - "remove_expired_cutoffs", - "remove_expired_release_creator_cutoffs", - "remove_release_creator_cutoffs", - "reviewed_cutoff", - "validate_indexes", - "validate_recent_cutoffs", - "validate_release_creator_cutoffs", -] diff --git a/tools/refresh_uv_resolver.py b/tools/refresh_uv_resolver.py deleted file mode 100644 index 05b00df..0000000 --- a/tools/refresh_uv_resolver.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Keep lock bytes stable when the shared uv resolver changes.""" - -from __future__ import annotations - -import argparse -import tomllib -from pathlib import Path - -import tomlkit -from packaging.markers import InvalidMarker, Marker -from tomlkit.exceptions import ParseError - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("mode", choices=("widen", "pin")) - parser.add_argument("project", type=Path) - parser.add_argument("lock", type=Path) - args = parser.parse_args() - try: - project = tomlkit.parse(args.project.read_text()) - requirements = project["dependency-groups"]["release-resolver"] - if len(requirements) != 1: - raise ValueError("release-resolver must contain one uv requirement") - if args.mode == "widen": - if not str(requirements[0]).startswith("uv=="): - raise ValueError("release-resolver must have an exact uv pin") - requirements[0] = f"uv>={str(requirements[0]).removeprefix('uv==')}" - else: - if not str(requirements[0]).startswith("uv>="): - raise ValueError("release-resolver must have the temporary uv range") - lock = tomllib.loads(args.lock.read_text()) - if "release-resolver" not in lock["dependency-groups"]: - raise ValueError("candidate lock omits release-resolver") - selected = {"dependency_groups": frozenset({"release-resolver"}), "extras": frozenset()} - versions = { - package["version"] - for package in lock["packages"] - if package["name"] == "uv" - and ("marker" not in package or Marker(package["marker"]).evaluate(selected, "lock_file")) - } - if len(versions) != 1: - raise ValueError("candidate lock must select one uv resolver version") - requirements[0] = f"uv=={versions.pop()}" - args.project.write_text(tomlkit.dumps(project)) - except (InvalidMarker, OSError, KeyError, TypeError, ValueError, ParseError, tomllib.TOMLDecodeError) as error: - parser.error(str(error)) - - -if __name__ == "__main__": - main() - - -__all__ = [ - "main", -] diff --git a/tools/verify_cutoff_patch.py b/tools/verify_cutoff_patch.py deleted file mode 100644 index 5242515..0000000 --- a/tools/verify_cutoff_patch.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Allow only expired package cutoffs to change in pyproject.toml.""" - -from __future__ import annotations - -import argparse -import os -import re -import subprocess -import tomllib -from datetime import UTC, datetime, timedelta -from pathlib import Path - -from nab_cutoffs import expired_cutoffs, expired_release_creator_cutoffs, remove_cutoffs, remove_release_creator_cutoffs - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("project", nargs="?", type=Path, default=Path("pyproject.toml")) - args = parser.parse_args() - try: - baseline = subprocess.run( - ["git", "show", f"HEAD:{args.project.as_posix()}"], - check=True, - capture_output=True, - text=True, - ).stdout - original = tomllib.loads(baseline) - actual = tomllib.loads(args.project.read_text()) - threshold = datetime.now(UTC) - timedelta(days=7) - if "nab" in original["tool"]: - names, positions = expired_cutoffs(baseline, threshold) - before = original["tool"]["nab"] - after = actual["tool"]["nab"] - removed_names = { - name for name in names if after.get("packages", {}).get(name, {}).get("uploaded-prior-to") is None - } - removed_positions = { - index for index in positions if before["package-rules"][index] not in after.get("package-rules", []) - } - remove_cutoffs(before, removed_names, removed_positions) - if "release-creator" in original["tool"]: - names = expired_release_creator_cutoffs(baseline, threshold) - before = original["tool"]["release-creator"].get("locks", {}) - after = actual["tool"]["release-creator"].get("locks", {}) - removed_names = {name for name in names if name not in after.get("exclude-newer-package", {})} - if removed_names: - remove_release_creator_cutoffs(before, removed_names) - resolvers = [project.get("dependency-groups", {}).get("release-resolver") for project in (original, actual)] - if ( - os.environ.get("GITHUB_REPOSITORY") == "tox-dev/github-workflows" - and resolvers[0] != resolvers[1] - and all( - isinstance(pins, list) and len(pins) == 1 and re.fullmatch(r"uv==[0-9.]+", str(pins[0])) - for pins in resolvers - ) - ): - version = resolvers[1][0].removeprefix("uv==") - if not any( - package.get("name") == "uv" - and package.get("version") == version - and '"release-resolver" in dependency_groups' in package.get("marker", "") - for package in tomllib.loads(args.project.with_name("pylock.toml").read_text())["packages"] - ): - raise ValueError(f"pylock.toml does not lock the updated resolver uv {version}") - original["dependency-groups"]["release-resolver"] = resolvers[1] - if original != actual: - producer = "package" if "release-creator" in original["tool"] else "Nab" - parser.error(f"pyproject.toml has changes beyond expired {producer} cutoffs") - except (OSError, KeyError, TypeError, ValueError, subprocess.CalledProcessError, tomllib.TOMLDecodeError) as error: - parser.error(str(error)) - - -if __name__ == "__main__": - main() - - -__all__ = [ - "main", -] diff --git a/validate-news/action.yml b/validate-news/action.yml index 185e240..ef88d66 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -1,9 +1,6 @@ name: Validate changelog news description: Require a fragment for human changes and validate any fragment supplied by a bot inputs: - keep-uv-lock: - description: Check uv.lock alongside pylock.toml when both are committed - default: "false" additional-lock: description: Secondary pylock basename to check on a pull request default: "" @@ -47,11 +44,9 @@ runs: env: BASE_REF: ${{ github.base_ref }} ADDITIONAL_LOCK: ${{ inputs.additional-lock }} - KEEP_UV_LOCK: ${{ inputs.keep-uv-lock }} run: | paths=(':(glob)**/pyproject.toml' pylock.toml nab.toml ':(glob).github/workflows/**') if [[ -n "$ADDITIONAL_LOCK" ]]; then paths+=("$ADDITIONAL_LOCK"); fi - if [[ "$KEEP_UV_LOCK" == true ]]; then paths+=(uv.lock); fi changed=$(git diff --name-only "origin/$BASE_REF...HEAD" -- "${paths[@]}") if [[ -z "$changed" ]]; then echo 'changed=false' >>"$GITHUB_OUTPUT" @@ -76,5 +71,4 @@ runs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} - keep-uv-lock: ${{ inputs.keep-uv-lock }} mode: check diff --git a/verify-cutoff-patch/action.yml b/verify-cutoff-patch/action.yml deleted file mode 100644 index 9074fc0..0000000 --- a/verify-cutoff-patch/action.yml +++ /dev/null @@ -1,8 +0,0 @@ -name: Verify cutoff patch -description: Reject pyproject changes other than expired package cutoffs and the shared resolver pin -runs: - using: composite - steps: - - name: Verify pyproject policy - shell: bash - run: python3 "$GITHUB_ACTION_PATH/../tools/verify_cutoff_patch.py" From 244494f5e603595def6d522e85003ecaef5043c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 12:53:41 -0700 Subject: [PATCH 07/35] =?UTF-8?q?=F0=9F=94=A5=20feat(lock)!:=20drop=20the?= =?UTF-8?q?=20local=20release-creator=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove install-local-release-creator and the local-tool-source input of install-locked-tools and the prepare workflow, along with the matching exemption in check_lock_groups.py. --- .../workflows/prepare-towncrier-release.yaml | 24 ++-------- README.md | 17 +++---- changelog.d/+local-tool-source.breaking.md | 2 + changelog.d/+nab-primary-lock.breaking.md | 8 ++-- changelog.d/+writer-allowlist.breaking.md | 4 +- install-local-release-creator/action.yml | 20 --------- install-locked-tools/action.yml | 9 +--- tests/install_locked_tools/test_install.py | 44 ++----------------- tests/install_locked_tools/test_metadata.py | 27 +++--------- tests/release_workflow/test_graph.py | 43 ------------------ tools/check_lock_groups.py | 32 +++++--------- 11 files changed, 37 insertions(+), 193 deletions(-) create mode 100644 changelog.d/+local-tool-source.breaking.md delete mode 100644 install-local-release-creator/action.yml diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 061ef80..f4871ed 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -14,10 +14,6 @@ on: description: Optional v prefix for the Towncrier changelog heading type: string default: "" - local-tool-source: - description: Install release-creator from the tagged checkout for its own release - type: boolean - default: false additional-lock: description: Secondary Nab lock to check before release preparation type: string @@ -88,12 +84,10 @@ jobs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} - - id: tools - uses: $/install-locked-tools + - uses: $/install-locked-tools with: - groups: release-prepare${{ inputs.local-tool-source && ' project-runtime' || '' }} + groups: release-prepare venv: .venv - local-tool-source: ${{ inputs.local-tool-source }} - name: Install prek hooks id: hooks env: @@ -106,10 +100,6 @@ jobs: unset UV_INSECURE_HOST UV_SYSTEM_CERTS SSL_CERT_FILE SSL_CERT_DIR prek install-hooks --config .pre-commit-config.yaml background: true - - uses: $/install-local-release-creator - if: inputs.local-tool-source - with: - build-constraints: ${{ steps.tools.outputs.build-constraints }} - id: prepare name: Build changelog uses: $/prepare-towncrier @@ -146,16 +136,10 @@ jobs: version: latest-known python-version: "3.15" enable-cache: false - - id: tools - uses: $/install-locked-tools + - uses: $/install-locked-tools with: - groups: release-validate${{ inputs.local-tool-source && ' project-runtime' || '' }} + groups: release-validate venv: .venv - local-tool-source: ${{ inputs.local-tool-source }} - - uses: $/install-local-release-creator - if: inputs.local-tool-source - with: - build-constraints: ${{ steps.tools.outputs.build-constraints }} - id: config uses: $/read-towncrier-config - name: Verify selected release version diff --git a/README.md b/README.md index 460e774..f3caf21 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,9 @@ least seven days before the lock was created. A package that the caller's `[tool `[[tool.nab.package-rules]]` table gives an `uploaded-prior-to` override may be as new as that override allows: a timestamp, a `PnD` duration before the lock's creation, or `false` for no cutoff. -Select the groups each job needs. The shared workflows use `release-news release-shell`, -`release-hook-check release-notes lock-reader`, and `release-prepare project-runtime` or -`release-validate project-runtime` for release-creator's own build. A full lock records each group and extra, but a new -combination still needs an install check before a caller uses it. +Select the groups each job needs. The shared workflows use `release-news release-shell`, `release-prepare`, and +`release-validate`. A full lock records each group and extra, but a new combination still needs an install check before +a caller uses it. - `venv`: directory for the virtual environment when `groups` is set. - `groups`: whitespace-separated release dependency groups. Leave empty to export build constraints without installing @@ -30,8 +29,6 @@ combination still needs an install check before a caller uses it. - `lock-source`: `caller` (default) installs from the checkout's lock; `shared` installs from the pinned Action's lock. Both paths use the pinned Action's lock-tool bootstrap. The Action checks that every selected group exists in the selected lock and applies hashed build constraints. -- `local-tool-source`: set to `true` only when release-creator builds and installs its own wheel after this Action. The - caller's lock then needs the selected groups but does not need a release-creator package entry. - `constraints-lock`: lock used to export build constraints; defaults to the selected install lock. Use a secondary lock when the build matrix differs from the release-tool matrix. - `verify-lock` and `verify-groups`: check another lock's selected packages against PyPI and the seven-day cutoff @@ -306,11 +303,9 @@ workflow handles. `bump`, `tag-prefix`, and `changelog-prefix` have the same meanings as the `prepare-towncrier` inputs below. `annotated` selects an annotated tag. `release-auth-deployment` defaults to `true`; set it to `false` if the current App-key job -uses `release-auth` without creating a GitHub Deployment. `local-tool-source: true` builds release-creator's wheel from -the checkout and installs it with locked third-party dependencies in assembly; use it for release-creator itself. The -caller can place repository-specific checks in a preceding job and make this job depend on it. One dispatch prepares one -tag; run separate dispatches for separate packages. The tag-triggered ecosystem publisher stays in the caller -repository. +uses `release-auth` without creating a GitHub Deployment. The caller can place repository-specific checks in a preceding +job and make this job depend on it. One dispatch prepares one tag; run separate dispatches for separate packages. The +tag-triggered ecosystem publisher stays in the caller repository. ## Read Towncrier configuration diff --git a/changelog.d/+local-tool-source.breaking.md b/changelog.d/+local-tool-source.breaking.md new file mode 100644 index 0000000..3510f63 --- /dev/null +++ b/changelog.d/+local-tool-source.breaking.md @@ -0,0 +1,2 @@ +Remove the `install-local-release-creator` Action and the `local-tool-source` input of `install-locked-tools` and +`prepare-towncrier-release.yaml`. A lock may no longer list release-creator as a local directory package. diff --git a/changelog.d/+nab-primary-lock.breaking.md b/changelog.d/+nab-primary-lock.breaking.md index 63aebde..0c9e4e6 100644 --- a/changelog.d/+nab-primary-lock.breaking.md +++ b/changelog.d/+nab-primary-lock.breaking.md @@ -1,4 +1,4 @@ -Generate the primary `pylock.toml` with `nab lock --all-groups --all-extras --no-cache`, adding `--upgrade` in -`refresh` mode, and compare a regenerated lock in `check` mode. The `release-creator locks` path and the uv resolver -steps are gone, so relock a `release-creator locks` lock with Nab before re-pinning. Nab runs without `SSL_CERT_FILE` -and `SSL_CERT_DIR`. +Generate the primary `pylock.toml` with `nab lock --all-groups --all-extras --no-cache`, adding `--upgrade` in `refresh` +mode, and compare a regenerated lock in `check` mode. The `release-creator locks` path and the uv resolver steps are +gone, so relock a `release-creator locks` lock with Nab before re-pinning. Nab runs without `SSL_CERT_FILE` and +`SSL_CERT_DIR`. diff --git a/changelog.d/+writer-allowlist.breaking.md b/changelog.d/+writer-allowlist.breaking.md index 7faaae9..edaf11e 100644 --- a/changelog.d/+writer-allowlist.breaking.md +++ b/changelog.d/+writer-allowlist.breaking.md @@ -1,2 +1,2 @@ -Accept only `pylock.toml` and the configured secondary lock in the `upgrade-release-tools.yaml` patch. -`pyproject.toml` and `uv.lock` changes are rejected. +Accept only `pylock.toml` and the configured secondary lock in the `upgrade-release-tools.yaml` patch. `pyproject.toml` +and `uv.lock` changes are rejected. diff --git a/install-local-release-creator/action.yml b/install-local-release-creator/action.yml deleted file mode 100644 index 08a557a..0000000 --- a/install-local-release-creator/action.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Install local release-creator -description: Build release-creator from the checkout into .venv and put its command first on PATH -inputs: - build-constraints: - description: Hashed build constraints from install-locked-tools - required: true -runs: - using: composite - steps: - - name: Build and install the local release-creator wheel - shell: bash - env: - BUILD_CONSTRAINTS: ${{ inputs.build-constraints }} - run: | - set -euo pipefail - uv build --wheel --build-constraints "$BUILD_CONSTRAINTS" \ - --require-build-hashes --out-dir "$RUNNER_TEMP/local-tool-dist" . - uv pip install --python .venv/bin/python --no-deps "$RUNNER_TEMP/local-tool-dist/"*.whl - uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ - "$GITHUB_ACTION_PATH/../tools/expose_locked_commands.py" .venv diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index abef02d..fd0dca7 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -10,9 +10,6 @@ inputs: lock-source: description: Install groups from the caller or the shared Action lock default: caller - local-tool-source: - description: The caller builds release-creator from its own checkout after installing locked third-party tools - default: "false" constraints-lock: description: Lock used to export build constraints; defaults to the selected install lock default: "" @@ -58,7 +55,6 @@ runs: DEPENDENCY_GROUPS: ${{ inputs.groups }} VENV: ${{ inputs.venv }} LOCK_SOURCE: ${{ inputs.lock-source }} - LOCAL_TOOL_SOURCE: ${{ inputs.local-tool-source }} run: | set -euo pipefail case "$LOCK_SOURCE" in @@ -87,12 +83,9 @@ runs: fi # The shared lock is checked in github-workflows CI; only a caller lock needs the cooling and PyPI checks. if [[ "$LOCK_SOURCE" == caller ]]; then - source_check=() - if [[ "$LOCAL_TOOL_SOURCE" == true ]]; then source_check=(--local-tool-source); fi uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" "${selection[@]}" \ - --shared-lock "$GITHUB_ACTION_PATH/../pylock.toml" --verify-pypi --project pyproject.toml \ - "${source_check[@]}" + --shared-lock "$GITHUB_ACTION_PATH/../pylock.toml" --verify-pypi --project pyproject.toml fi printf 'lock=%s\n' "$lock" >>"$GITHUB_OUTPUT" - name: Verify additional release lock diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index aec4080..172cdd9 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -207,8 +207,7 @@ def test_install_locked_tools_rejects_missing_group(tmp_path: Path, action_scrip assert "missing lock dependency groups: relesae" in result.stderr -@pytest.mark.parametrize("local_source", [False, True], ids=["locked-caller", "local-source"]) -def test_install_locked_tools_checks_caller_release_creator(tmp_path: Path, local_source: bool) -> None: +def test_install_locked_tools_checks_caller_release_creator(tmp_path: Path) -> None: lock = tmp_path / "pylock.toml" lock.write_text( 'lock-version = "1.0"\nrequires-python = ">=3.15"\n' @@ -224,46 +223,9 @@ def test_install_locked_tools_checks_caller_release_creator(tmp_path: Path, loca "--shared-lock", str(CALLER / "pylock.toml"), ] - if local_source: - command.append("--local-tool-source") result = subprocess.run(command, text=True, capture_output=True, check=False) - assert (result.returncode == 0) is local_source, result.stderr - - -@pytest.mark.parametrize( - ("package_name", "accepted"), - [ - pytest.param("release-creator", True, id="local-release-creator"), - pytest.param("towncrier", False, id="other-local-package"), - ], -) -def test_local_tool_source_only_accepts_release_creator(tmp_path: Path, package_name: str, accepted: bool) -> None: - source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((CALLER / "pylock.toml").read_text()) - package = next(item for item in document["packages"] if item["name"] == package_name) - for field in ("version", "index", "sdist", "wheels"): - package.pop(field) - package["directory"] = {"path": "."} - (tmp_path / "pylock.toml").write_text(tomlkit.dumps(document)) - result = subprocess.run( - [ - "python", - str(source / "tools/check_lock_groups.py"), - str(tmp_path / "pylock.toml"), - "release-prepare", - "--shared-lock", - str(CALLER / "pylock.toml"), - "--local-tool-source", - "--project", - str(CALLER / "pyproject.toml"), - ], - text=True, - capture_output=True, - check=False, - ) - assert (result.returncode == 0) is accepted, result.stderr - if not accepted: - assert f"direct-source package: {package_name}" in result.stderr + assert result.returncode != 0 + assert "relock: release-creator" in result.stderr @pytest.mark.parametrize( diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py index de94c81..7e9e035 100644 --- a/tests/install_locked_tools/test_metadata.py +++ b/tests/install_locked_tools/test_metadata.py @@ -323,41 +323,24 @@ def test_lock_metadata_rejects_non_object_listing( assert "invalid PyPI listing for six" in capsys.readouterr().err -def test_lock_metadata_allows_local_release_creator( - lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch -) -> None: - lock = lock_case[0] - lock.write_text( - lock.read_text().split("[[packages]]", 1)[0] - + '[[packages]]\nname = "release-creator"\nmarker = \'"release-check" in dependency_groups\'\n' - + 'directory = { path = "." }\n' - ) - - _run_check(lock_case, monkeypatch, "--local-tool-source", "--verify-pypi", "--project", str(lock_case[1])) - - -@pytest.mark.parametrize( - ("name", "path"), - [pytest.param("release-creator", "../other", id="other-directory"), pytest.param("six", ".", id="other-package")], -) -def test_lock_metadata_limits_local_tool_exemption( +@pytest.mark.parametrize("path", [".", "../other"], ids=["checkout", "other-directory"]) +def test_lock_metadata_rejects_local_release_creator( lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], - name: str, path: str, ) -> None: lock = lock_case[0] lock.write_text( lock.read_text().split("[[packages]]", 1)[0] - + f'[[packages]]\nname = "{name}"\nmarker = \'"release-check" in dependency_groups\'\n' + + '[[packages]]\nname = "release-creator"\nmarker = \'"release-check" in dependency_groups\'\n' + f'directory = {{ path = "{path}" }}\n' ) with pytest.raises(SystemExit, match="2"): - _run_check(lock_case, monkeypatch, "--local-tool-source") + _run_check(lock_case, monkeypatch) - assert "direct-source package" in capsys.readouterr().err + assert "direct-source package: release-creator" in capsys.readouterr().err @pytest.mark.parametrize( diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index e2fa411..869415b 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -76,49 +76,6 @@ def test_prepare_validation_calculates_version_independently( assert "assembled release version differs from 1.2.3" in result.stderr -def test_local_release_creator_builds_with_locked_backends(tmp_path: Path) -> None: - step = yaml.safe_load( - (Path(__file__).resolve().parents[2] / "install-local-release-creator/action.yml").read_text() - )["runs"]["steps"][0] - fake_bin = tmp_path / "fake-bin" - fake_bin.mkdir() - (fake_bin / "uv").write_text( - '#!/bin/sh\necho "$@" >>"$RUNNER_TEMP/uv-calls"\n' - 'if [ "$1" = build ]; then mkdir -p "$RUNNER_TEMP/local-tool-dist" && touch "$RUNNER_TEMP/local-tool-dist/x.whl"; fi\n' - f'if [ "$1" = run ]; then shift 4; shift; exec {sys.executable} "$@"; fi\n' - ) - (fake_bin / "uv").chmod(0o755) - command = tmp_path / ".venv/bin/release-creator" - command.parent.mkdir(parents=True) - command.symlink_to(sys.executable) - output = tmp_path / "github-path" - - result = subprocess.run( - ["bash", "-e", "-o", "pipefail", "-c", step["run"]], - cwd=tmp_path, - env=os.environ - | { - "PATH": f"{fake_bin}:{os.environ['PATH']}", - "BUILD_CONSTRAINTS": "constraints.txt", - "GITHUB_WORKSPACE": str(tmp_path), - "RUNNER_TEMP": str(tmp_path), - "GITHUB_PATH": str(output), - "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "install-local-release-creator"), - }, - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0, result.stderr - build, install, _ = (tmp_path / "uv-calls").read_text().splitlines() - assert "--build-constraints constraints.txt --require-build-hashes" in build - assert install.endswith(f"--no-deps {tmp_path}/local-tool-dist/x.whl") - overlay = Path(output.read_text().strip()) - assert [path.name for path in overlay.iterdir()] == ["release-creator"] - assert (overlay / "release-creator").resolve() == command.resolve() - - def test_release_writer_has_only_notes_and_reconciler() -> None: workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) publish = workflow["jobs"]["publish"] diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index b31001e..41cf9e9 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -34,7 +34,6 @@ def main() -> None: parser.add_argument("lock", type=Path) parser.add_argument("groups", nargs="*") parser.add_argument("--shared-lock", type=Path) - parser.add_argument("--local-tool-source", action="store_true") parser.add_argument("--build-constraints", action="store_true") parser.add_argument("--verify-pypi", action="store_true") parser.add_argument("--project", type=Path) @@ -64,14 +63,14 @@ def main() -> None: parser.error(f"missing lock dependency groups: {', '.join(sorted(missing))}") if args.build_constraints and "build" not in (lock.dependency_groups or ()): groups.clear() - _check_cooling(lock, groups, nab, local_tool_source=args.local_tool_source) + _check_cooling(lock, groups, nab) if args.verify_pypi: - _verify_pypi(lock, groups, local_tool_source=args.local_tool_source) + _verify_pypi(lock, groups) if args.shared_lock: with args.shared_lock.open("rb") as stream: shared = Pylock.from_dict(tomllib.load(stream)) required = max(_selected_release_creators(shared, groups), default=None) - if required is not None and not args.local_tool_source: + if required is not None: installed = min(_selected_release_creators(lock, groups), default=None) if installed is None or installed < required: parser.error(f"relock: release-creator {required} required") @@ -133,7 +132,7 @@ def _validate_indexes(nab: dict) -> None: raise ValueError("custom Nab indexes are not approved for release locks") -def _check_cooling(lock: Pylock, groups: set[str], nab: dict, *, local_tool_source: bool = False) -> None: +def _check_cooling(lock: Pylock, groups: set[str], nab: dict) -> None: producer = (lock.tool or {}).get(lock_provider(lock.tool)) if not isinstance(producer, dict): raise TypeError("lock producer settings must be a table") @@ -143,7 +142,7 @@ def _check_cooling(lock: Pylock, groups: set[str], nab: dict, *, local_tool_sour threshold = created - timedelta(days=7) cutoffs = _package_cutoffs(nab, created) whole_seconds = lock.created_by == "release-creator (uv)" and lock_provider(lock.tool) == "release-creator" - for package in _selected_packages(lock, groups, local_tool_source=local_tool_source): + for package in _selected_packages(lock, groups): if package.archive is not None or package.vcs is not None or package.directory is not None: raise ValueError(f"release lock has a direct-source package: {package.name}") if package.index is None: @@ -199,9 +198,9 @@ def _cutoff(name: str, value: object, created: datetime) -> datetime: return value -def _verify_pypi(lock: Pylock, groups: set[str], *, local_tool_source: bool) -> None: +def _verify_pypi(lock: Pylock, groups: set[str]) -> None: expected: dict[str, list[tuple[str, str | None, str, datetime | None]]] = defaultdict(list) - for package in _selected_packages(lock, groups, local_tool_source=local_tool_source): + for package in _selected_packages(lock, groups): for artifact in ([package.sdist] if package.sdist is not None else []) + list(package.wheels or ()): expected[canonicalize_name(package.name)].append( ( @@ -247,22 +246,11 @@ def _verify_package_metadata(item: tuple[str, list[tuple[str, str | None, str, d raise ValueError(f"release lock differs from PyPI metadata: {name}: {mismatch}") -def _selected_packages(lock: Pylock, groups: set[str], *, local_tool_source: bool) -> Iterator[Package]: +def _selected_packages(lock: Pylock, groups: set[str]) -> Iterator[Package]: selections = (groups, {"build"}) if "build" in (lock.dependency_groups or ()) else (groups,) for package in lock.packages: - if package.marker is not None and not any( - _possibly_selected(package.marker, selection) for selection in selections - ): - continue - if ( - local_tool_source - and package.name == "release-creator" - and package.directory is not None - and package.directory.path == "." - and package.index is None - ): - continue - yield package + if package.marker is None or any(_possibly_selected(package.marker, selection) for selection in selections): + yield package def _possibly_selected(marker: Marker, groups: set[str]) -> bool: From f8d9f651553d0111ee5a84ef17c7d2e006ff5989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:04:42 -0700 Subject: [PATCH 08/35] =?UTF-8?q?=F0=9F=94=A5=20feat(tools)!:=20install=20?= =?UTF-8?q?the=20Actions'=20tools=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete the shared pylock.toml, update-tools.yaml and every lock group. The Actions install nab, certifi, packaging and pypi-attestations, and gw's own CI installs release-creator, Towncrier, prek and yq, with uv pip install --exclude-newer through tools/install_live_tools.sh, without hashes. Each Action and reusable workflow that installs tools gains exclude-newer, 7 days by default, and exclude-newer-package. install-locked-tools loses lock-source and installs only from the caller lock; build constraints no longer merge the shared build pins. --- .github/workflows/check.yaml | 45 +- .github/workflows/news.yaml | 14 +- .../workflows/prepare-towncrier-release.yaml | 14 + .github/workflows/release.yaml | 20 +- .github/workflows/update-tools.yaml | 17 - .github/workflows/upgrade-release-tools.yaml | 10 + .../workflows/validate-python-release.yaml | 13 + CONTRIBUTING.md | 15 +- README.md | 58 +- build-python-distribution/action.yml | 10 + changelog.d/+exclude-newer.feature.md | 3 + changelog.d/+shared-lock.breaking.md | 4 + check-tagged-source/action.yml | 8 + export-build-constraints/action.yml | 9 +- install-locked-tools/action.yml | 68 +- pylock.toml | 4851 ----------------- pyproject.toml | 23 - reconcile-pypi-artifacts/action.yml | 19 +- refresh-release-lock/action.yml | 17 +- tests/conftest.py | 33 +- tests/export_build_constraints/test_export.py | 6 +- .../test_install_live_tools.py | 97 + tests/install_locked_tools/test_install.py | 309 +- .../test_secondary_lock.py | 21 +- tests/refresh_release_lock/test_refresh.py | 18 + tests/validate_news/test_news.py | 45 + tools/check_lock_groups.py | 17 +- tools/install_live_tools.sh | 13 + validate-news/action.yml | 8 + 29 files changed, 421 insertions(+), 5364 deletions(-) delete mode 100644 .github/workflows/update-tools.yaml create mode 100644 changelog.d/+exclude-newer.feature.md create mode 100644 changelog.d/+shared-lock.breaking.md delete mode 100644 pylock.toml create mode 100644 tests/install_live_tools/test_install_live_tools.py create mode 100755 tools/install_live_tools.sh diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 54ff96d..d3cfa71 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -30,13 +30,13 @@ jobs: python-version: "3.15" enable-cache: true cache-suffix: ${{ github.job }} - cache-dependency-glob: | - pylock.toml - pyproject.toml - - uses: $/install-locked-tools - with: - groups: release-check - venv: .release-tools + cache-dependency-glob: pyproject.toml + - name: Install release tools + env: + EXCLUDE_NEWER: 7 days + run: | + tools/install_live_tools.sh .release-tools 'prek>=0.2' + uv run --no-project --python .release-tools python tools/expose_locked_commands.py .release-tools - name: Check repository formatting run: tools/cooled_prek.sh run --all-files shell: @@ -58,13 +58,13 @@ jobs: python-version: "3.15" enable-cache: true cache-suffix: ${{ github.job }} - cache-dependency-glob: | - pylock.toml - pyproject.toml - - uses: $/install-locked-tools - with: - groups: release-shell - venv: .release-tools + cache-dependency-glob: pyproject.toml + - name: Install release tools + env: + EXCLUDE_NEWER: 7 days + run: | + tools/install_live_tools.sh .release-tools 'prek>=0.2' 'yq>=4.4.5' + uv run --no-project --python .release-tools python tools/expose_locked_commands.py .release-tools - name: Check inline shell run: | set -euo pipefail @@ -98,13 +98,12 @@ jobs: python-version: "3.15" enable-cache: true cache-suffix: ${{ github.job }} - cache-dependency-glob: | - pylock.toml - pyproject.toml - - uses: $/install-locked-tools - with: - groups: release-news release-shell - venv: .release-tools + cache-dependency-glob: pyproject.toml + - name: Install release tools + env: + EXCLUDE_NEWER: 7 days + run: | + tools/install_live_tools.sh .release-tools 'release-creator>=2.0.0' 'towncrier>=26.9' 'prek>=0.2' 'yq>=4.4.5' - name: Run Action tests run: uvx --with tox-uv tox run -e test type: @@ -121,9 +120,7 @@ jobs: python-version: "3.15" enable-cache: true cache-suffix: ${{ github.job }} - cache-dependency-glob: | - pylock.toml - pyproject.toml + cache-dependency-glob: pyproject.toml - name: Check Python types run: uvx --with tox-uv tox run -e type gate: diff --git a/.github/workflows/news.yaml b/.github/workflows/news.yaml index f42c886..876ba77 100644 --- a/.github/workflows/news.yaml +++ b/.github/workflows/news.yaml @@ -18,11 +18,11 @@ jobs: python-version: "3.15" enable-cache: true cache-suffix: ${{ github.job }} - cache-dependency-glob: | - pylock.toml - pyproject.toml - - uses: $/install-locked-tools - with: - groups: release-news - venv: ${{ runner.temp }}/release-tools + cache-dependency-glob: pyproject.toml + - name: Install release tools + env: + EXCLUDE_NEWER: 7 days + run: | + tools/install_live_tools.sh "$RUNNER_TEMP/release-tools" 'release-creator>=2.0.0' 'towncrier>=26.9' + uv run --no-project --python "$RUNNER_TEMP/release-tools" python tools/expose_locked_commands.py "$RUNNER_TEMP/release-tools" - uses: $/validate-news diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index f4871ed..9281c95 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -46,6 +46,14 @@ on: description: Create a GitHub Deployment for the App-key job type: boolean default: true + exclude-newer: + description: Upload cutoff for the tools the shared Actions install from PyPI + type: string + default: 7 days + exclude-newer-package: + description: Newline-separated name=date cutoffs that override exclude-newer for single packages + type: string + default: "" secrets: RELEASE_APP_PRIVATE_KEY: description: Key held by the caller's release-auth environment @@ -84,10 +92,14 @@ jobs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} - uses: $/install-locked-tools with: groups: release-prepare venv: .venv + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} - name: Install prek hooks id: hooks env: @@ -140,6 +152,8 @@ jobs: with: groups: release-validate venv: .venv + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} - id: config uses: $/read-towncrier-config - name: Verify selected release version diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8ad693f..4a473c4 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -42,10 +42,12 @@ jobs: version: latest-known python-version: "3.15" enable-cache: false - - uses: $/install-locked-tools - with: - groups: release-notes - venv: .venv + - name: Install release tools + env: + EXCLUDE_NEWER: 7 days + run: | + tools/install_live_tools.sh .venv 'release-creator>=2.0.0' + uv run --no-project --python .venv python tools/expose_locked_commands.py .venv - name: Verify tagged source env: TAG: ${{ github.ref_name }} @@ -99,10 +101,12 @@ jobs: version: latest-known python-version: "3.15" enable-cache: false - - uses: $/install-locked-tools - with: - groups: release-notes - venv: .venv + - name: Install release tools + env: + EXCLUDE_NEWER: 7 days + run: | + tools/install_live_tools.sh .venv 'release-creator>=2.0.0' + uv run --no-project --python .venv python tools/expose_locked_commands.py .venv - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: artifact-ids: ${{ needs.contributors.outputs.artifact-id }} diff --git a/.github/workflows/update-tools.yaml b/.github/workflows/update-tools.yaml deleted file mode 100644 index 313dce5..0000000 --- a/.github/workflows/update-tools.yaml +++ /dev/null @@ -1,17 +0,0 @@ -name: Update release tools -on: - workflow_dispatch: - schedule: - - cron: "17 9 * * 2" -permissions: {} -jobs: - update: - uses: $/.github/workflows/upgrade-release-tools.yaml - permissions: - contents: read - secrets: - RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - with: - commit-message: "🔧 build: refresh release tool lock" - pull-request-title: "🔧 build: refresh release tool lock" - pull-request-body: "Refresh the locked tools used by release preparation and CI." diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index 0a0b0f1..39a2c13 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -39,6 +39,14 @@ on: type: string default: |- Refresh the project lock and exact pre-commit hook dependency pins. + exclude-newer: + description: Upload cutoff for the tools the shared Actions install from PyPI + type: string + default: 7 days + exclude-newer-package: + description: Newline-separated name=date cutoffs that override exclude-newer for single packages + type: string + default: "" secrets: RELEASE_APP_PRIVATE_KEY: description: Repo-only release App key @@ -71,6 +79,8 @@ jobs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} - id: upload uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: steps.lock.outputs.changed == 'true' diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index bbe27b1..af0b437 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -38,6 +38,14 @@ on: description: Space-separated implementations for the secondary lock default: "" type: string + exclude-newer: + description: Upload cutoff for the tools the shared Actions install from PyPI + default: 7 days + type: string + exclude-newer-package: + description: Newline-separated name=date cutoffs that override exclude-newer for single packages + default: "" + type: string permissions: contents: read jobs: @@ -66,6 +74,8 @@ jobs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} constraints: runs-on: ubuntu-26.04 outputs: @@ -84,6 +94,9 @@ jobs: enable-cache: false - id: constraints uses: $/install-locked-tools + with: + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} - name: Stage build constraints env: SOURCE: ${{ steps.constraints.outputs.build-constraints }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9871625..eba1495 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,20 +5,15 @@ and test its inputs and outputs before opening a pull request. ## Set up -Use Python 3.15. Install the release tools from the lock and test tools from the current index: +Use Python 3.15. Install the release tools under the seven-day cutoff, as CI does, and test tools from the current +index: ```bash uv venv --allow-existing --python 3.15 .venv -uv venv --allow-existing --python 3.15 .venv/lock-tools -uv pip sync --preview-features pylock --python .venv/lock-tools/bin/python --group lock-tools \ - --only-binary :all: --require-hashes pylock.toml -.venv/lock-tools/bin/python tools/build_constraints.py .venv/build-constraints.txt pylock.toml -uv venv --allow-existing --python 3.15 .venv/release-tools -uv pip sync --preview-features pylock --preview-features build-dependency-hashes \ - --python .venv/release-tools/bin/python --group release-hooks \ - --build-constraints .venv/build-constraints.txt --require-build-hashes pylock.toml +EXCLUDE_NEWER="7 days" tools/install_live_tools.sh .release-tools \ + 'release-creator>=2.0.0' 'towncrier>=26.9' 'prek>=0.2' 'yq>=4.4.5' uv pip install --python .venv/bin/python --group test -export PATH="$PWD/.venv/release-tools/bin:$PWD/.venv/bin:$PATH" +export PATH="$PWD/.release-tools/bin:$PWD/.venv/bin:$PATH" ``` Run the checks before pushing: diff --git a/README.md b/README.md index f3caf21..f3da982 100644 --- a/README.md +++ b/README.md @@ -6,41 +6,37 @@ keeps its dispatch form, publisher, GitHub environment, and required check names ## Install locked tools -`install-locked-tools` creates a virtual environment and installs selected groups from the caller's `pylock.toml` by -default. `lock-source: shared` selects this Action repository's `pylock.toml` for shared jobs such as hook maintenance -that do not need the caller's release tools. The caller's full project lock records runtime dependencies, every extra, -and every dependency group across supported Python versions and platforms. CI resolves test, type, and documentation -dependencies fresh; release jobs install only the selected release groups. The Action rejects missing groups and leaves -tracked files unchanged. Source builds use constraints generated from the lock's `build` group under the runner's -temporary directory. +`install-locked-tools` creates a virtual environment and installs selected groups from the caller's `pylock.toml`. The +caller's full project lock records runtime dependencies, every extra, and every dependency group across supported Python +versions and platforms. CI resolves test, type, and documentation dependencies fresh; release jobs install only the +selected release groups. The Action rejects missing groups and leaves tracked files unchanged. Source builds use +constraints generated from the lock's `build` group under the runner's temporary directory. A lock without a `build` +group exports an empty constraints file. Every selected package must come from PyPI with the recorded hashes and upload times, and must have been uploaded at least seven days before the lock was created. A package that the caller's `[tool.nab.packages.]` or `[[tool.nab.package-rules]]` table gives an `uploaded-prior-to` override may be as new as that override allows: a timestamp, a `PnD` duration before the lock's creation, or `false` for no cutoff. -Select the groups each job needs. The shared workflows use `release-news release-shell`, `release-prepare`, and -`release-validate`. A full lock records each group and extra, but a new combination still needs an install check before -a caller uses it. +Select the groups each job needs. The shared workflows use `release-prepare` and `release-validate`. A full lock records +each group and extra, but a new combination still needs an install check before a caller uses it. - `venv`: directory for the virtual environment when `groups` is set. - `groups`: whitespace-separated release dependency groups. Leave empty to export build constraints without installing release tools. -- `lock-source`: `caller` (default) installs from the checkout's lock; `shared` installs from the pinned Action's lock. - Both paths use the pinned Action's lock-tool bootstrap. The Action checks that every selected group exists in the - selected lock and applies hashed build constraints. -- `constraints-lock`: lock used to export build constraints; defaults to the selected install lock. Use a secondary lock - when the build matrix differs from the release-tool matrix. +- `constraints-lock`: lock used to export build constraints; defaults to `pylock.toml`. Use a secondary lock when the + build matrix differs from the release-tool matrix. Without `groups`, the Action checks only this lock. - `verify-lock` and `verify-groups`: check another lock's selected packages against PyPI and the seven-day cutoff without installing them. Use this before bundling packages from a separate runtime lock, such as a zipapp. - `target-platform` and `targets`: Nab platform ID and space-separated `full-version:implementation` pairs for build constraints. Pass both when exporting constraints for a specific build target; otherwise the runner selects the target. +- `exclude-newer` and `exclude-newer-package`: cutoffs for the lock reader the Action installs from PyPI, see + [Tools the Actions install](#tools-the-actions-install). The `build-constraints` output names the hashed constraints file. The Action adds installed command launchers to `GITHUB_PATH` for later steps while leaving the runner's `python`, `pip`, and `uv` commands on their original paths. Run -`actions/checkout` and `astral-sh/setup-uv` with Python 3.15 before it. It bootstraps its lock reader from the shared, -hash-checked lock. +`actions/checkout` and `astral-sh/setup-uv` with Python 3.15 before it. For a zipapp that bundles wheels from a separate runtime lock, check that lock in the same job before building it: @@ -56,6 +52,27 @@ For a zipapp that bundles wheels from a separate runtime lock, check that lock i `export-build-constraints` also accepts `verify-groups`. Pass it when its secondary lock supplies build tools outside the `build` group, such as profile-guided optimization tools in a `pgo` group. +## Tools the Actions install + +The Actions install the tools they run themselves, such as Nab, `packaging`, `certifi`, and `pypi-attestations`, from +PyPI with `uv pip install --exclude-newer`, without hashes. There is no shared lock, so a tool release reaches callers +once it is older than the cutoff. Every Action and reusable workflow that installs such a tool takes two inputs: + +- `exclude-newer`: the cutoff, `7 days` by default. uv accepts a timestamp or a duration. +- `exclude-newer-package`: newline-separated `name=date` pairs, each passed to uv as `--exclude-newer-package`, to let a + single package past the cutoff in an emergency. + +```yaml + - uses: tox-dev/github-workflows/refresh-release-lock@SHA + with: + mode: check + exclude-newer-package: | + nab=2026-10-24T00:00:00Z +``` + +The tools the caller's release jobs run, such as release-creator, Towncrier, and prek, still come hash-checked from the +caller's `pylock.toml`. + ## Check pull request news `validate-news` reads `tool.towncrier` from `pyproject.toml`. It checks every added or modified fragment in the @@ -483,9 +500,10 @@ invalid attestation fails. The GitHub Release writer must depend on this verific dist: dist ``` -`project`, `version`, and `dist` are required. The Action installs the locked `release-attestations` group on Python -3.15 and waits up to 120 seconds for PyPI's listing to propagate. It checks the complete local file set, then runs -`pypi-attestations verify pypi` on each local file against the caller repository. It prints no credentials. +`project`, `version`, and `dist` are required. The Action installs `pypi-attestations` on Python 3.15 under the +`exclude-newer` cutoff and waits up to 120 seconds for PyPI's listing to propagate. It checks the complete local file +set, then runs `pypi-attestations verify pypi` on each local file against the caller repository. It prints no +credentials. ## Publish a GitHub Release diff --git a/build-python-distribution/action.yml b/build-python-distribution/action.yml index bf28edb..ec8e9a1 100644 --- a/build-python-distribution/action.yml +++ b/build-python-distribution/action.yml @@ -1,5 +1,12 @@ name: Build Python distribution description: Build a wheel and sdist for a keyless PR check +inputs: + exclude-newer: + description: Upload cutoff for the tools this Action installs from PyPI + default: 7 days + exclude-newer-package: + description: Newline-separated name=date cutoffs that override exclude-newer for single packages + default: "" runs: using: composite steps: @@ -16,6 +23,9 @@ runs: run: uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/build-python" - id: constraints uses: $/export-build-constraints + with: + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} - name: Build wheel and sdist shell: bash env: diff --git a/changelog.d/+exclude-newer.feature.md b/changelog.d/+exclude-newer.feature.md new file mode 100644 index 0000000..65bf2bc --- /dev/null +++ b/changelog.d/+exclude-newer.feature.md @@ -0,0 +1,3 @@ +Add `exclude-newer` and `exclude-newer-package` inputs to every Action and reusable workflow that installs its own +tools. `exclude-newer` defaults to `7 days`; `exclude-newer-package` takes newline-separated `name=date` overrides for +an emergency. diff --git a/changelog.d/+shared-lock.breaking.md b/changelog.d/+shared-lock.breaking.md new file mode 100644 index 0000000..238f795 --- /dev/null +++ b/changelog.d/+shared-lock.breaking.md @@ -0,0 +1,4 @@ +Remove the Actions' shared `pylock.toml`, its weekly refresh, and the `lock-source` input of `install-locked-tools`. +The Actions now install their own tools from PyPI under a seven-day cutoff, and `install-locked-tools` installs only +from the caller's `pylock.toml`. Build constraints come from the caller lock's `build` group alone, without the shared +Cython and setuptools pins. diff --git a/check-tagged-source/action.yml b/check-tagged-source/action.yml index 9187a4d..d5aec15 100644 --- a/check-tagged-source/action.yml +++ b/check-tagged-source/action.yml @@ -19,6 +19,12 @@ inputs: additional-matrix-implementations: description: Space-separated implementations for the secondary lock default: "" + exclude-newer: + description: Upload cutoff for the tools this Action installs from PyPI + default: 7 days + exclude-newer-package: + description: Newline-separated name=date cutoffs that override exclude-newer for single packages + default: "" runs: using: composite steps: @@ -41,3 +47,5 @@ runs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} diff --git a/export-build-constraints/action.yml b/export-build-constraints/action.yml index 1304ebb..caad7df 100644 --- a/export-build-constraints/action.yml +++ b/export-build-constraints/action.yml @@ -13,6 +13,12 @@ inputs: verify-groups: description: Additional lock groups whose packages enter the release build default: "" + exclude-newer: + description: Upload cutoff for the tools this Action installs from PyPI + default: 7 days + exclude-newer-package: + description: Newline-separated name=date cutoffs that override exclude-newer for single packages + default: "" outputs: path: description: Path to the generated constraints for this lock selection @@ -23,9 +29,10 @@ runs: - id: tools uses: $/install-locked-tools with: - lock-source: shared constraints-lock: ${{ inputs.lock }} verify-lock: ${{ inputs.verify-groups != '' && inputs.lock || '' }} verify-groups: ${{ inputs.verify-groups }} target-platform: ${{ inputs.target-platform }} targets: ${{ inputs.targets }} + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index fd0dca7..9effc4f 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -7,9 +7,6 @@ inputs: venv: description: Virtual environment directory default: "" - lock-source: - description: Install groups from the caller or the shared Action lock - default: caller constraints-lock: description: Lock used to export build constraints; defaults to the selected install lock default: "" @@ -25,6 +22,12 @@ inputs: targets: description: Space-separated full-version:implementation pairs for the exported build constraints default: "" + exclude-newer: + description: Upload cutoff for the lock reader this Action installs from PyPI + default: 7 days + exclude-newer-package: + description: Newline-separated name=date cutoffs that override exclude-newer for single packages + default: "" outputs: build-constraints: description: Hashed build requirements selected from the lock @@ -37,34 +40,20 @@ runs: run: uv venv --allow-existing --python 3.15 "$RUNNER_TEMP/lock-reader" - name: Install lock reader shell: bash - run: | - set -euo pipefail - unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE - digest=$(sha256sum "$GITHUB_ACTION_PATH/../pylock.toml") - if [[ -f "$RUNNER_TEMP/lock-reader/.lock-digest" && - $(cat "$RUNNER_TEMP/lock-reader/.lock-digest") == "${digest%% *}" ]]; then - exit 0 - fi - uv pip sync --preview-features pylock --python "$RUNNER_TEMP/lock-reader" --group lock-reader \ - --only-binary :all: --require-hashes "$GITHUB_ACTION_PATH/../pylock.toml" - printf '%s\n' "${digest%% *}" >"$RUNNER_TEMP/lock-reader/.lock-digest" + env: + EXCLUDE_NEWER: ${{ inputs.exclude-newer }} + EXCLUDE_NEWER_PACKAGE: ${{ inputs.exclude-newer-package }} + run: >- + "$GITHUB_ACTION_PATH/../tools/install_live_tools.sh" "$RUNNER_TEMP/lock-reader" 'certifi>=2026.7.22' 'packaging>=26.3' - id: selection name: Select locked groups and build constraints shell: bash env: DEPENDENCY_GROUPS: ${{ inputs.groups }} VENV: ${{ inputs.venv }} - LOCK_SOURCE: ${{ inputs.lock-source }} + CONSTRAINTS_LOCK: ${{ inputs.constraints-lock }} run: | set -euo pipefail - case "$LOCK_SOURCE" in - caller) lock=pylock.toml ;; - shared) lock="$GITHUB_ACTION_PATH/../pylock.toml" ;; - *) - echo "::error::unknown lock source: $LOCK_SOURCE" >&2 - exit 1 - ;; - esac groups=() while IFS= read -r line; do read -ra selected <<<"$line" @@ -81,13 +70,15 @@ runs: else selection=(--build-constraints) fi - # The shared lock is checked in github-workflows CI; only a caller lock needs the cooling and PyPI checks. - if [[ "$LOCK_SOURCE" == caller ]]; then + # An export from a named constraints lock checks that lock instead of the primary one. + checked=false + if ((${#groups[@]})) || [[ -z "$CONSTRAINTS_LOCK" ]]; then uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ - "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" "${selection[@]}" \ - --shared-lock "$GITHUB_ACTION_PATH/../pylock.toml" --verify-pypi --project pyproject.toml + "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" pylock.toml "${selection[@]}" \ + --verify-pypi --project pyproject.toml + checked=true fi - printf 'lock=%s\n' "$lock" >>"$GITHUB_OUTPUT" + printf 'lock=pylock.toml\nchecked=%s\n' "$checked" >>"$GITHUB_OUTPUT" - name: Verify additional release lock if: inputs.verify-lock != '' || inputs.verify-groups != '' shell: bash @@ -113,6 +104,7 @@ runs: shell: bash env: BUILD_LOCK: ${{ steps.selection.outputs.lock }} + CHECKED: ${{ steps.selection.outputs.checked }} CONSTRAINTS_LOCK: ${{ inputs.constraints-lock }} VERIFY_LOCK: ${{ inputs.verify-lock }} TARGET_PLATFORM: ${{ inputs.target-platform }} @@ -120,36 +112,26 @@ runs: run: | set -euo pipefail # A verified lock already had its build selection checked against PyPI with its groups. - if [[ -n "$CONSTRAINTS_LOCK" && "$CONSTRAINTS_LOCK" != "$BUILD_LOCK" && "$CONSTRAINTS_LOCK" != "$VERIFY_LOCK" ]]; then - lock_check=() - if [[ "$CONSTRAINTS_LOCK" != "$GITHUB_ACTION_PATH/../pylock.toml" ]]; then - lock_check=(--verify-pypi --project pyproject.toml) - fi + if [[ -n "$CONSTRAINTS_LOCK" && "$CONSTRAINTS_LOCK" != "$VERIFY_LOCK" && + ("$CHECKED" != true || "$CONSTRAINTS_LOCK" != "$BUILD_LOCK") ]]; then uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$CONSTRAINTS_LOCK" --build-constraints \ - "${lock_check[@]}" + --verify-pypi --project pyproject.toml fi if [[ -n "$CONSTRAINTS_LOCK" ]]; then BUILD_LOCK=$CONSTRAINTS_LOCK; fi args=() - if [[ "$BUILD_LOCK" == pylock.toml ]]; then - args+=(--shared-policy "$GITHUB_ACTION_PATH/../pyproject.toml") - locks=("$GITHUB_ACTION_PATH/../pylock.toml" pylock.toml) - else - locks=("$BUILD_LOCK") - fi if [[ -n "$TARGET_PLATFORM" || -n "$TARGETS" ]]; then args+=(--target-platform "$TARGET_PLATFORM" --targets "$TARGETS") fi digest=$( - sha256sum "${locks[@]}" + sha256sum "$BUILD_LOCK" printf '%s\n' "$TARGET_PLATFORM" "$TARGETS" "${args[@]}" ) digest=$(sha256sum <<<"$digest") output="$RUNNER_TEMP/build-constraints-${digest%% *}.txt" if [[ ! -f "$output" ]]; then uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ - "$GITHUB_ACTION_PATH/../tools/build_constraints.py" "${args[@]}" \ - "$output" "${locks[@]}" + "$GITHUB_ACTION_PATH/../tools/build_constraints.py" "${args[@]}" "$output" "$BUILD_LOCK" fi echo "path=$output" >>"$GITHUB_OUTPUT" - name: Install selected groups diff --git a/pylock.toml b/pylock.toml deleted file mode 100644 index 7d05603..0000000 --- a/pylock.toml +++ /dev/null @@ -1,4851 +0,0 @@ -lock-version = "1.0" -created-by = "release-creator (uv)" -requires-python = ">=3.15" -extras = [] -dependency-groups = [ - "project-runtime", - "build", - "lock-tools", - "lock-reader", - "release-notes", - "release-resolver", - "release-mise-editor", - "release-news", - "release-prepare", - "release-validate", - "release-check", - "release-attestations", - "release-shell", - "release-hooks", - "release-hook-check", - "test", -] -default-groups = [] - -[[packages]] -name = "annotated-types" -version = "0.8.0" -marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz" -upload-time = 2026-07-23 20:16:13+00:00 -size = 15893 - -[packages.sdist.hashes] -sha256 = "13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl" -upload-time = 2026-07-23 20:16:12+00:00 -size = 13427 - -[packages.wheels.hashes] -sha256 = "f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0" - -[[packages]] -name = "argcomplete" -version = "3.7.2" -marker = "\"release-hook-check\" in dependency_groups or \"release-shell\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/87/6f/5a73f04007ca950701765949209f068da628bd11f9c2da287278ce91e0ee/argcomplete-3.7.2.tar.gz" -upload-time = 2026-08-06 04:53:21+00:00 -size = 74473 - -[packages.sdist.hashes] -sha256 = "aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl" -upload-time = 2026-08-06 04:53:20+00:00 -size = 43294 - -[packages.wheels.hashes] -sha256 = "6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19" - -[[packages]] -name = "attrs" -version = "26.1.0" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz" -upload-time = 2026-03-19 14:22:25+00:00 -size = 952055 - -[packages.sdist.hashes] -sha256 = "d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl" -upload-time = 2026-03-19 14:22:23+00:00 -size = 67548 - -[packages.wheels.hashes] -sha256 = "c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309" - -[[packages]] -name = "build" -version = "1.6.1" -marker = "\"lock-tools\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/bd/67/4898a44ea4f3f8e213b0954ec0aa0a16971d62a6212d6ea3931e97115b99/build-1.6.1.tar.gz" -upload-time = 2026-09-10 07:56:00+00:00 -size = 113427 - -[packages.sdist.hashes] -sha256 = "51cc11666391ab6f092070437ac747002ff46f3e4113a3622177ee6b488bfc53" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ad/9b/9fb3585dabcd73a1b2a6267f63f62649347c9e6d072c9fde365b105abb2c/build-1.6.1-py3-none-any.whl" -upload-time = 2026-09-10 07:55:59+00:00 -size = 31179 - -[packages.wheels.hashes] -sha256 = "ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7" - -[[packages]] -name = "cachetools" -version = "7.2.3" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/09/88/27fe0785c7a9aa285c753db0b4ea967adbf76128fede66379c35a4b8d054/cachetools-7.2.3.tar.gz" -upload-time = 2026-10-17 11:41:25+00:00 -size = 43727 - -[packages.sdist.hashes] -sha256 = "e6fafd06de4a5bd915ff7d247588ea497e28c9a568c62db8678d9a5f11515a9a" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/fb/dd/044062b61d94c59c29ad0da6918e6a21106f5b115096e9c87081cdd96167/cachetools-7.2.3-py3-none-any.whl" -upload-time = 2026-10-17 11:41:24+00:00 -size = 18352 - -[packages.wheels.hashes] -sha256 = "85ef9d2703918bedf7f88caa0b06bb3bb7a71360eb0b05c20796b89fede6e30c" - -[[packages]] -name = "certifi" -version = "2026.7.22" -marker = "\"lock-reader\" in dependency_groups or \"release-attestations\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz" -upload-time = 2026-07-22 03:35:12+00:00 -size = 138112 - -[packages.sdist.hashes] -sha256 = "741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl" -upload-time = 2026-07-22 03:35:11+00:00 -size = 136983 - -[packages.wheels.hashes] -sha256 = "62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775" - -[[packages]] -name = "cffi" -version = "2.1.1" -marker = "(\"release-attestations\" in dependency_groups and (platform_python_implementation != 'PyPy')) or (\"test\" in dependency_groups and (platform_python_implementation != 'PyPy'))" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz" -upload-time = 2026-08-03 21:21:18+00:00 -size = 530807 - -[packages.sdist.hashes] -sha256 = "dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl" -upload-time = 2026-08-03 21:20:48+00:00 -size = 194043 - -[packages.wheels.hashes] -sha256 = "dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl" -upload-time = 2026-08-03 21:20:49+00:00 -size = 196737 - -[packages.wheels.hashes] -sha256 = "da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl" -upload-time = 2026-08-03 21:20:50+00:00 -size = 184933 - -[packages.wheels.hashes] -sha256 = "fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl" -upload-time = 2026-08-03 21:20:52+00:00 -size = 185002 - -[packages.wheels.hashes] -sha256 = "2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" -upload-time = 2026-08-03 21:20:53+00:00 -size = 222271 - -[packages.wheels.hashes] -sha256 = "507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl" -upload-time = 2026-08-03 21:20:54+00:00 -size = 209919 - -[packages.wheels.hashes] -sha256 = "246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl" -upload-time = 2026-08-03 21:20:56+00:00 -size = 208529 - -[packages.wheels.hashes] -sha256 = "471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" -upload-time = 2026-08-03 21:20:57+00:00 -size = 221630 - -[packages.wheels.hashes] -sha256 = "aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl" -upload-time = 2026-08-03 21:20:58+00:00 -size = 225134 - -[packages.wheels.hashes] -sha256 = "42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl" -upload-time = 2026-08-03 21:20:59+00:00 -size = 223197 - -[packages.wheels.hashes] -sha256 = "cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl" -upload-time = 2026-08-03 21:21:14+00:00 -size = 177683 - -[packages.wheels.hashes] -sha256 = "4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl" -upload-time = 2026-08-03 21:21:16+00:00 -size = 187897 - -[packages.wheels.hashes] -sha256 = "e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl" -upload-time = 2026-08-03 21:21:17+00:00 -size = 182935 - -[packages.wheels.hashes] -sha256 = "27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl" -upload-time = 2026-08-03 21:21:01+00:00 -size = 188464 - -[packages.wheels.hashes] -sha256 = "c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl" -upload-time = 2026-08-03 21:21:02+00:00 -size = 188262 - -[packages.wheels.hashes] -sha256 = "4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" -upload-time = 2026-08-03 21:21:03+00:00 -size = 223779 - -[packages.wheels.hashes] -sha256 = "937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl" -upload-time = 2026-08-03 21:21:04+00:00 -size = 211520 - -[packages.wheels.hashes] -sha256 = "df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl" -upload-time = 2026-08-03 21:21:06+00:00 -size = 210673 - -[packages.wheels.hashes] -sha256 = "a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" -upload-time = 2026-08-03 21:21:07+00:00 -size = 223835 - -[packages.wheels.hashes] -sha256 = "363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl" -upload-time = 2026-08-03 21:21:08+00:00 -size = 226705 - -[packages.wheels.hashes] -sha256 = "770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl" -upload-time = 2026-08-03 21:21:09+00:00 -size = 225539 - -[packages.wheels.hashes] -sha256 = "7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl" -upload-time = 2026-08-03 21:21:11+00:00 -size = 182707 - -[packages.wheels.hashes] -sha256 = "06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl" -upload-time = 2026-08-03 21:21:12+00:00 -size = 193772 - -[packages.wheels.hashes] -sha256 = "d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl" -upload-time = 2026-08-03 21:21:13+00:00 -size = 186360 - -[packages.wheels.hashes] -sha256 = "d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692" - -[[packages]] -name = "charset-normalizer" -version = "3.5.2" -marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz" -upload-time = 2026-09-30 04:39:23+00:00 -size = 172659 - -[packages.sdist.hashes] -sha256 = "39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl" -upload-time = 2026-09-30 04:37:28+00:00 -size = 367676 - -[packages.wheels.hashes] -sha256 = "87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26" - -[[packages.wheels]] -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" -upload-time = 2026-09-30 04:37:30+00:00 -size = 248414 - -[packages.wheels.hashes] -sha256 = "9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364" - -[[packages.wheels]] -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" -upload-time = 2026-09-30 04:37:31+00:00 -size = 244775 - -[packages.wheels.hashes] -sha256 = "498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253" - -[[packages.wheels]] -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" -upload-time = 2026-09-30 04:37:33+00:00 -size = 273877 - -[packages.wheels.hashes] -sha256 = "e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0" - -[[packages.wheels]] -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" -upload-time = 2026-09-30 04:37:35+00:00 -size = 269455 - -[packages.wheels.hashes] -sha256 = "def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc" - -[[packages.wheels]] -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" -upload-time = 2026-09-30 04:37:37+00:00 -size = 257336 - -[packages.wheels.hashes] -sha256 = "3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229" - -[[packages.wheels]] -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" -upload-time = 2026-09-30 04:37:39+00:00 -size = 257584 - -[packages.wheels.hashes] -sha256 = "04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl" -upload-time = 2026-09-30 04:37:41+00:00 -size = 251854 - -[packages.wheels.hashes] -sha256 = "183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl" -upload-time = 2026-09-30 04:37:42+00:00 -size = 248767 - -[packages.wheels.hashes] -sha256 = "16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl" -upload-time = 2026-09-30 04:37:44+00:00 -size = 274479 - -[packages.wheels.hashes] -sha256 = "7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl" -upload-time = 2026-09-30 04:37:46+00:00 -size = 258310 - -[packages.wheels.hashes] -sha256 = "ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl" -upload-time = 2026-09-30 04:37:48+00:00 -size = 271100 - -[packages.wheels.hashes] -sha256 = "df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl" -upload-time = 2026-09-30 04:37:50+00:00 -size = 259936 - -[packages.wheels.hashes] -sha256 = "f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl" -upload-time = 2026-09-30 04:37:51+00:00 -size = 186301 - -[packages.wheels.hashes] -sha256 = "281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl" -upload-time = 2026-09-30 04:37:53+00:00 -size = 210858 - -[packages.wheels.hashes] -sha256 = "89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl" -upload-time = 2026-09-30 04:37:55+00:00 -size = 200309 - -[packages.wheels.hashes] -sha256 = "6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl" -upload-time = 2026-09-30 04:37:56+00:00 -size = 395343 - -[packages.wheels.hashes] -sha256 = "915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1" - -[[packages.wheels]] -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" -upload-time = 2026-09-30 04:37:58+00:00 -size = 250114 - -[packages.wheels.hashes] -sha256 = "65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b" - -[[packages.wheels]] -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" -upload-time = 2026-09-30 04:38:00+00:00 -size = 239997 - -[packages.wheels.hashes] -sha256 = "b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f" - -[[packages.wheels]] -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" -upload-time = 2026-09-30 04:38:02+00:00 -size = 272313 - -[packages.wheels.hashes] -sha256 = "e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3" - -[[packages.wheels]] -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" -upload-time = 2026-09-30 04:38:04+00:00 -size = 269986 - -[packages.wheels.hashes] -sha256 = "124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e" - -[[packages.wheels]] -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" -upload-time = 2026-09-30 04:38:06+00:00 -size = 260202 - -[packages.wheels.hashes] -sha256 = "28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9" - -[[packages.wheels]] -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" -upload-time = 2026-09-30 04:38:08+00:00 -size = 259083 - -[packages.wheels.hashes] -sha256 = "58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl" -upload-time = 2026-09-30 04:38:10+00:00 -size = 253081 - -[packages.wheels.hashes] -sha256 = "443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl" -upload-time = 2026-09-30 04:38:12+00:00 -size = 248712 - -[packages.wheels.hashes] -sha256 = "58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl" -upload-time = 2026-09-30 04:38:14+00:00 -size = 274176 - -[packages.wheels.hashes] -sha256 = "1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl" -upload-time = 2026-09-30 04:38:15+00:00 -size = 259413 - -[packages.wheels.hashes] -sha256 = "ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl" -upload-time = 2026-09-30 04:38:17+00:00 -size = 269434 - -[packages.wheels.hashes] -sha256 = "5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl" -upload-time = 2026-09-30 04:38:19+00:00 -size = 262716 - -[packages.wheels.hashes] -sha256 = "51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl" -upload-time = 2026-09-30 04:38:21+00:00 -size = 197159 - -[packages.wheels.hashes] -sha256 = "5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl" -upload-time = 2026-09-30 04:38:22+00:00 -size = 221322 - -[packages.wheels.hashes] -sha256 = "a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl" -upload-time = 2026-09-30 04:38:24+00:00 -size = 209192 - -[packages.wheels.hashes] -sha256 = "749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl" -upload-time = 2026-09-30 04:38:26+00:00 -size = 331815 - -[packages.wheels.hashes] -sha256 = "4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd" - -[[packages.wheels]] -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" -upload-time = 2026-09-30 04:38:28+00:00 -size = 253276 - -[packages.wheels.hashes] -sha256 = "1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7" - -[[packages.wheels]] -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" -upload-time = 2026-09-30 04:38:29+00:00 -size = 241239 - -[packages.wheels.hashes] -sha256 = "d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f" - -[[packages.wheels]] -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" -upload-time = 2026-09-30 04:38:31+00:00 -size = 231121 - -[packages.wheels.hashes] -sha256 = "0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93" - -[[packages.wheels]] -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" -upload-time = 2026-09-30 04:38:33+00:00 -size = 260350 - -[packages.wheels.hashes] -sha256 = "838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade" - -[[packages.wheels]] -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" -upload-time = 2026-09-30 04:38:34+00:00 -size = 255430 - -[packages.wheels.hashes] -sha256 = "2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0" - -[[packages.wheels]] -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" -upload-time = 2026-09-30 04:38:36+00:00 -size = 250612 - -[packages.wheels.hashes] -sha256 = "0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl" -upload-time = 2026-09-30 04:38:38+00:00 -size = 242083 - -[packages.wheels.hashes] -sha256 = "1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl" -upload-time = 2026-09-30 04:38:39+00:00 -size = 232738 - -[packages.wheels.hashes] -sha256 = "304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl" -upload-time = 2026-09-30 04:38:41+00:00 -size = 260703 - -[packages.wheels.hashes] -sha256 = "9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl" -upload-time = 2026-09-30 04:38:42+00:00 -size = 247622 - -[packages.wheels.hashes] -sha256 = "01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl" -upload-time = 2026-09-30 04:38:44+00:00 -size = 257500 - -[packages.wheels.hashes] -sha256 = "7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl" -upload-time = 2026-09-30 04:38:46+00:00 -size = 255100 - -[packages.wheels.hashes] -sha256 = "9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl" -upload-time = 2026-09-30 04:38:47+00:00 -size = 174499 - -[packages.wheels.hashes] -sha256 = "01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl" -upload-time = 2026-09-30 04:38:49+00:00 -size = 200092 - -[packages.wheels.hashes] -sha256 = "9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl" -upload-time = 2026-09-30 04:38:51+00:00 -size = 294363 - -[packages.wheels.hashes] -sha256 = "40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl" -upload-time = 2026-09-30 04:39:21+00:00 -size = 68872 - -[packages.wheels.hashes] -sha256 = "b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685" - -[[packages]] -name = "check-wheel-contents" -version = "0.6.3" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/95/37/2b9e0d38c2f668791ffe8b97711185c364d91c027e47f189c371c2348d18/check_wheel_contents-0.6.3.tar.gz" -upload-time = 2025-08-02 14:01:45+00:00 -size = 586023 - -[packages.sdist.hashes] -sha256 = "10e6939e2fe4e6ce1edf2ff6ec6157808677e80782e78021ae139dd88473a442" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/be/05/f39fde9f31ef80b285ef5822fad4ddabf73fec62a1f02c5beb4b2f328972/check_wheel_contents-0.6.3-py3-none-any.whl" -upload-time = 2025-08-02 14:01:43+00:00 -size = 27541 - -[packages.wheels.hashes] -sha256 = "5ae39c8c434b972f0740d04610759168590713175aab584b012b1b84f6771874" - -[[packages]] -name = "click" -version = "8.5.0" -marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz" -upload-time = 2026-08-26 13:33:14+00:00 -size = 382235 - -[packages.sdist.hashes] -sha256 = "ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl" -upload-time = 2026-08-26 13:33:12+00:00 -size = 125251 - -[packages.wheels.hashes] -sha256 = "255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360" - -[[packages]] -name = "colorama" -version = "0.4.6" -marker = "\"test\" in dependency_groups or (\"lock-tools\" in dependency_groups and (os_name == 'nt'))" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz" -upload-time = 2022-10-25 02:36:22+00:00 -size = 27697 - -[packages.sdist.hashes] -sha256 = "08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl" -upload-time = 2022-10-25 02:36:20+00:00 -size = 25335 - -[packages.wheels.hashes] -sha256 = "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" - -[[packages]] -name = "coverage" -version = "7.16.2" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz" -upload-time = 2026-09-27 12:29:01+00:00 -size = 971999 - -[packages.sdist.hashes] -sha256 = "ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/4c/57/52935003c3f627ba6e5203d7179aad32448c10899663a30336aba8e81a2c/coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl" -upload-time = 2026-09-27 12:27:59+00:00 -size = 224140 - -[packages.wheels.hashes] -sha256 = "414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl" -upload-time = 2026-09-27 12:28:01+00:00 -size = 224449 - -[packages.wheels.hashes] -sha256 = "00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24" - -[[packages.wheels]] -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" -upload-time = 2026-09-27 12:28:03+00:00 -size = 256060 - -[packages.wheels.hashes] -sha256 = "4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09" - -[[packages.wheels]] -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" -upload-time = 2026-09-27 12:28:05+00:00 -size = 259106 - -[packages.wheels.hashes] -sha256 = "8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843" - -[[packages.wheels]] -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" -upload-time = 2026-09-27 12:28:06+00:00 -size = 260674 - -[packages.wheels.hashes] -sha256 = "9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1" - -[[packages.wheels]] -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" -upload-time = 2026-09-27 12:28:08+00:00 -size = 263139 - -[packages.wheels.hashes] -sha256 = "aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/14/5e/7c805ac9a32606de1399bd7e9bd375aa2f973dc61b12680d9e6403c2e891/coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl" -upload-time = 2026-09-27 12:28:10+00:00 -size = 256591 - -[packages.wheels.hashes] -sha256 = "9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ab/9e/76f1ed129a2daf658a3ea17122824cf2e3b91fea0460d8d3664fc5a61018/coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl" -upload-time = 2026-09-27 12:28:12+00:00 -size = 258708 - -[packages.wheels.hashes] -sha256 = "80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/5a/b7/8d62e75f48b527619239a65294f842d4b7fd02a0839d43ae1de80184e2df/coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl" -upload-time = 2026-09-27 12:28:14+00:00 -size = 256562 - -[packages.wheels.hashes] -sha256 = "7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b8/8d/0a15f95c3afb78e947c52644786ba4bc9de259905687dd720d5e6fae2e76/coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl" -upload-time = 2026-09-27 12:28:17+00:00 -size = 261077 - -[packages.wheels.hashes] -sha256 = "44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/25/00/88389987305a47d732866c07c8a500000ab574df9505e3114ac69c8d027f/coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl" -upload-time = 2026-09-27 12:28:19+00:00 -size = 256034 - -[packages.wheels.hashes] -sha256 = "59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/92/02/34d079d4952ad461bde037d353f9a6e037a7edc45fe0f9ee8781ff73f028/coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl" -upload-time = 2026-09-27 12:28:21+00:00 -size = 258034 - -[packages.wheels.hashes] -sha256 = "066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f6/d8/3e59a62879285b464ec1b10fd824fbc1af9ce66e842cd39974f80a0becc4/coverage-7.16.2-cp315-cp315-win32.whl" -upload-time = 2026-09-27 12:28:23+00:00 -size = 226252 - -[packages.wheels.hashes] -sha256 = "893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f4/e1/128026e1b2836e9ad6b219207ba9edf1c5e0088a7869e23088aee7fbbe7a/coverage-7.16.2-cp315-cp315-win_amd64.whl" -upload-time = 2026-09-27 12:28:25+00:00 -size = 226728 - -[packages.wheels.hashes] -sha256 = "01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a8/f4/c9fa8e7cf525ca7748ac52b0ee89331d13fe09808e45c679830708782e90/coverage-7.16.2-cp315-cp315-win_arm64.whl" -upload-time = 2026-09-27 12:28:27+00:00 -size = 226648 - -[packages.wheels.hashes] -sha256 = "967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a2/13/e96b045447a856666f36f9c653e2a80bdaa732aaaf72412b19aa2c26a473/coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl" -upload-time = 2026-09-27 12:28:29+00:00 -size = 224858 - -[packages.wheels.hashes] -sha256 = "98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/23/90/087f6ad1bd3df059632ca3407a4e6552ed1053ee35354de0a771acf35423/coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl" -upload-time = 2026-09-27 12:28:31+00:00 -size = 225139 - -[packages.wheels.hashes] -sha256 = "3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55" - -[[packages.wheels]] -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" -upload-time = 2026-09-27 12:28:33+00:00 -size = 265090 - -[packages.wheels.hashes] -sha256 = "fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2" - -[[packages.wheels]] -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" -upload-time = 2026-09-27 12:28:35+00:00 -size = 268254 - -[packages.wheels.hashes] -sha256 = "eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2" - -[[packages.wheels]] -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" -upload-time = 2026-09-27 12:28:36+00:00 -size = 270695 - -[packages.wheels.hashes] -sha256 = "444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1" - -[[packages.wheels]] -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" -upload-time = 2026-09-27 12:28:38+00:00 -size = 271853 - -[packages.wheels.hashes] -sha256 = "a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/38/29/bf8072b1b8bd5f2de8b21460a404460b1a2b97e80a9464c78ec0271f6199/coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl" -upload-time = 2026-09-27 12:28:40+00:00 -size = 265644 - -[packages.wheels.hashes] -sha256 = "8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/7c/2f/0aecb8721be5cdeb8afd9d6d9f6b463f074e4d8d37f00f4c42442522709f/coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl" -upload-time = 2026-09-27 12:28:42+00:00 -size = 268530 - -[packages.wheels.hashes] -sha256 = "396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ab/0b/92b4b7628268ee711249958e68fc0328779bd3d9a7ab4715379465aedb84/coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl" -upload-time = 2026-09-27 12:28:44+00:00 -size = 265154 - -[packages.wheels.hashes] -sha256 = "9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/7b/d9/41c95c1ab29b3dcd357cd1227181d1c98185632aca41ce670ce671b23a43/coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl" -upload-time = 2026-09-27 12:28:46+00:00 -size = 269799 - -[packages.wheels.hashes] -sha256 = "fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/80/07/ebeb259aa5362b033a137b86d7274ff4b109d59be8cc9913889b783bf75a/coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl" -upload-time = 2026-09-27 12:28:48+00:00 -size = 265196 - -[packages.wheels.hashes] -sha256 = "bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b2/18/8437620f90d023680a072eee02f968055f3658bbfb7d386d0ea34cfb7f30/coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl" -upload-time = 2026-09-27 12:28:51+00:00 -size = 267266 - -[packages.wheels.hashes] -sha256 = "dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/28/6c/f08e8ee4293e6434035424180bef4d45e028e8ecc006c61bf9453e74405e/coverage-7.16.2-cp315-cp315t-win32.whl" -upload-time = 2026-09-27 12:28:53+00:00 -size = 226583 - -[packages.wheels.hashes] -sha256 = "251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f7/fd/3f939c2847f4a72c20cff8b1ac33da78ea91a2d38d9b43336e60db719103/coverage-7.16.2-cp315-cp315t-win_amd64.whl" -upload-time = 2026-09-27 12:28:55+00:00 -size = 227374 - -[packages.wheels.hashes] -sha256 = "2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/5a/35/b98cdc354c952402132e675a87f2cc3227fb68f959c84aaa491fbe15933d/coverage-7.16.2-cp315-cp315t-win_arm64.whl" -upload-time = 2026-09-27 12:28:57+00:00 -size = 227064 - -[packages.wheels.hashes] -sha256 = "b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl" -upload-time = 2026-09-27 12:28:59+00:00 -size = 215754 - -[packages.wheels.hashes] -sha256 = "11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f" - -[[packages]] -name = "cryptography" -version = "50.0.2" -marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz" -upload-time = 2026-09-30 15:30:04+00:00 -size = 880623 - -[packages.sdist.hashes] -sha256 = "7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl" -upload-time = 2026-09-30 14:43:44+00:00 -size = 3914904 - -[packages.wheels.hashes] -sha256 = "fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" -upload-time = 2026-09-30 14:43:47+00:00 -size = 4731146 - -[packages.wheels.hashes] -sha256 = "79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" -upload-time = 2026-09-30 14:43:49+00:00 -size = 4719841 - -[packages.wheels.hashes] -sha256 = "630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl" -upload-time = 2026-09-30 14:43:50+00:00 -size = 4738340 - -[packages.wheels.hashes] -sha256 = "f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl" -upload-time = 2026-09-30 14:43:52+00:00 -size = 5367029 - -[packages.wheels.hashes] -sha256 = "a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl" -upload-time = 2026-09-30 14:43:55+00:00 -size = 4753050 - -[packages.wheels.hashes] -sha256 = "4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl" -upload-time = 2026-09-30 14:43:57+00:00 -size = 4376724 - -[packages.wheels.hashes] -sha256 = "ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl" -upload-time = 2026-09-30 14:43:59+00:00 -size = 4737859 - -[packages.wheels.hashes] -sha256 = "87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl" -upload-time = 2026-09-30 14:44:01+00:00 -size = 5324103 - -[packages.wheels.hashes] -sha256 = "f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl" -upload-time = 2026-09-30 14:44:04+00:00 -size = 4752576 - -[packages.wheels.hashes] -sha256 = "9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl" -upload-time = 2026-09-30 14:44:06+00:00 -size = 4870819 - -[packages.wheels.hashes] -sha256 = "25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl" -upload-time = 2026-09-30 14:44:09+00:00 -size = 5030152 - -[packages.wheels.hashes] -sha256 = "85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl" -upload-time = 2026-09-30 14:44:11+00:00 -size = 3824692 - -[packages.wheels.hashes] -sha256 = "7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl" -upload-time = 2026-09-30 14:44:41+00:00 -size = 4133708 - -[packages.wheels.hashes] -sha256 = "edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" -upload-time = 2026-09-30 14:44:43+00:00 -size = 4956267 - -[packages.wheels.hashes] -sha256 = "d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" -upload-time = 2026-09-30 14:44:45+00:00 -size = 4966465 - -[packages.wheels.hashes] -sha256 = "f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl" -upload-time = 2026-09-30 14:44:48+00:00 -size = 4959356 - -[packages.wheels.hashes] -sha256 = "e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl" -upload-time = 2026-09-30 14:44:50+00:00 -size = 5548822 - -[packages.wheels.hashes] -sha256 = "b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl" -upload-time = 2026-09-30 14:44:53+00:00 -size = 5001199 - -[packages.wheels.hashes] -sha256 = "58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl" -upload-time = 2026-09-30 14:44:55+00:00 -size = 4629333 - -[packages.wheels.hashes] -sha256 = "d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl" -upload-time = 2026-09-30 14:44:59+00:00 -size = 4958822 - -[packages.wheels.hashes] -sha256 = "fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl" -upload-time = 2026-09-30 14:45:02+00:00 -size = 5506351 - -[packages.wheels.hashes] -sha256 = "cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl" -upload-time = 2026-09-30 14:45:05+00:00 -size = 5000859 - -[packages.wheels.hashes] -sha256 = "e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl" -upload-time = 2026-09-30 15:29:15+00:00 -size = 5092151 - -[packages.wheels.hashes] -sha256 = "6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl" -upload-time = 2026-09-30 15:29:18+00:00 -size = 5286120 - -[packages.wheels.hashes] -sha256 = "c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl" -upload-time = 2026-09-30 15:29:20+00:00 -size = 4111557 - -[packages.wheels.hashes] -sha256 = "c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl" -upload-time = 2026-09-30 15:29:22+00:00 -size = 3943588 - -[packages.wheels.hashes] -sha256 = "0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" -upload-time = 2026-09-30 15:29:24+00:00 -size = 4756166 - -[packages.wheels.hashes] -sha256 = "c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" -upload-time = 2026-09-30 15:29:26+00:00 -size = 4749145 - -[packages.wheels.hashes] -sha256 = "ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl" -upload-time = 2026-09-30 15:29:28+00:00 -size = 4763638 - -[packages.wheels.hashes] -sha256 = "dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl" -upload-time = 2026-09-30 15:29:30+00:00 -size = 5382217 - -[packages.wheels.hashes] -sha256 = "58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl" -upload-time = 2026-09-30 15:29:32+00:00 -size = 4781387 - -[packages.wheels.hashes] -sha256 = "f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl" -upload-time = 2026-09-30 15:29:34+00:00 -size = 4403790 - -[packages.wheels.hashes] -sha256 = "9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl" -upload-time = 2026-09-30 15:29:36+00:00 -size = 4764319 - -[packages.wheels.hashes] -sha256 = "0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl" -upload-time = 2026-09-30 15:29:39+00:00 -size = 5338560 - -[packages.wheels.hashes] -sha256 = "a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl" -upload-time = 2026-09-30 15:29:41+00:00 -size = 4780973 - -[packages.wheels.hashes] -sha256 = "1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl" -upload-time = 2026-09-30 15:29:43+00:00 -size = 4897738 - -[packages.wheels.hashes] -sha256 = "7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl" -upload-time = 2026-09-30 15:29:44+00:00 -size = 5058280 - -[packages.wheels.hashes] -sha256 = "ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl" -upload-time = 2026-09-30 15:29:46+00:00 -size = 3854095 - -[packages.wheels.hashes] -sha256 = "4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de" - -[[packages]] -name = "cython" -version = "3.3.0" -marker = "\"build\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/a9/d8/4981ef716ad0e3ff0d3ef383aefc6b03c4a88dee33b272bf8e0d833001ca/cython-3.3.0.tar.gz" -upload-time = 2026-08-22 05:16:39+00:00 -size = 3727515 - -[packages.sdist.hashes] -sha256 = "eed0d93fbca7087f143b42c34b05a825849bdf17f101572c2105acfa49aa88b8" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ea/45/7f6988070013e16918e39b1b3dab9c5f2c8e404253a7fd10ee685bbd6902/cython-3.3.0-cp315-cp315-macosx_11_0_arm64.whl" -upload-time = 2026-08-22 05:17:32+00:00 -size = 3177681 - -[packages.wheels.hashes] -sha256 = "596e8df019372a2cd417805015022d42cb8ee4e1803ccdc11ed00e451625fb66" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/6e/5d/afb6866ab10236bb208dff0f172ea4b397c9693c5250280c4d9d26057218/cython-3.3.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" -upload-time = 2026-08-22 05:17:34+00:00 -size = 3328312 - -[packages.wheels.hashes] -sha256 = "4a36c34d1950845b8ac148653b07cdc62421a4b0d9abfcc849e69f1c4ff9919d" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/c9/aa/4c0b6773ecf6bc1ec6cda7db8312a566611b330fb6dae87d740e44a47822/cython-3.3.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" -upload-time = 2026-08-22 05:17:36+00:00 -size = 3454285 - -[packages.wheels.hashes] -sha256 = "b447f6906e0555f05dc4742ef1f99091b1e5d9aa9f16616e772fbf9ff6271616" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/44/bb/3e2631122f96300723d6fc42b9cf65550bdcda570a6ad5c4e0226e2e787c/cython-3.3.0-cp315-cp315-win_amd64.whl" -upload-time = 2026-08-22 05:17:38+00:00 -size = 2924709 - -[packages.wheels.hashes] -sha256 = "b55c72e8eccdd508c8de3cf3bbc543aafbb3bf6a518e1ee20358d3241cd780ef" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/14/59/bc1a84b434cb5bebb0cd6f50da8f239d35a5c141b20fdeafc2817fd87778/cython-3.3.0-cp39-abi3-macosx_10_9_x86_64.whl" -upload-time = 2026-08-22 05:17:40+00:00 -size = 3063660 - -[packages.wheels.hashes] -sha256 = "e0d2713d2b292c826bc21dc8732bd9e47628103aa3764180c881e04b3fef95dc" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ba/6d/542e32908fb421d88354f327ed6450e14240f9825d25393065bc65f4723f/cython-3.3.0-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl" -upload-time = 2026-08-22 05:17:43+00:00 -size = 3358395 - -[packages.wheels.hashes] -sha256 = "169e56fd411f4cd5bba51c82f8239421d547a846099db2b261e4aed48ba9f51f" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/9c/7c/ddaf197bc65b581e1891657940bc4f7cb1f740e822115e828920b3a119ce/cython-3.3.0-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl" -upload-time = 2026-08-22 05:17:44+00:00 -size = 3041760 - -[packages.wheels.hashes] -sha256 = "29f38ebafdf23e3da2516f40c4d065da38bfe002181bf93e2b8cf1262449aba6" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/19/a7/ae5ec3e34d43da846ed4c425734752d83aae0dae49feb929f09c90fc9afa/cython-3.3.0-cp39-abi3-musllinux_1_2_aarch64.whl" -upload-time = 2026-08-22 05:17:46+00:00 -size = 3156152 - -[packages.wheels.hashes] -sha256 = "75c4ae8a6d3a5ccf3cdaba8ab32e6a8d0cd38e3a476aa7ac12df8f8171a8d570" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/31/44/c60b601fc43f0b08e9d6f14b94e0dd02eb0ca8d60f46e242ace7191ac1be/cython-3.3.0-cp39-abi3-musllinux_1_2_armv7l.whl" -upload-time = 2026-08-22 05:17:48+00:00 -size = 3046690 - -[packages.wheels.hashes] -sha256 = "b94fb5613b9fe34c27d13ec9972dc0dcd2a2155db2902e93921cadc162610a38" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b0/9e/d735c26ed907563d3365534006acb263651c2d3b87fee804f7a483dd1714/cython-3.3.0-cp39-abi3-musllinux_1_2_i686.whl" -upload-time = 2026-08-22 05:17:50+00:00 -size = 3373534 - -[packages.wheels.hashes] -sha256 = "c4558ba85849ab65dc57e10fd0efb13fabd9d3c09981a2566e18dec7cf47586a" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e0/e8/aa7b4f3a28d6e8117c76e2cf78a0df7a503486cdf7243c5b53200c9533a1/cython-3.3.0-cp39-abi3-musllinux_1_2_x86_64.whl" -upload-time = 2026-08-22 05:17:52+00:00 -size = 3265110 - -[packages.wheels.hashes] -sha256 = "311a016369adfd1e0015c4f9819168fc0e518451d7efb4435c30d65a3a26d52b" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/9c/66/37892a8999d6bbd3f92d691a9701cb720c8ddd6171e16f5148eee6e8cb7f/cython-3.3.0-cp39-abi3-win32.whl" -upload-time = 2026-08-22 05:17:54+00:00 -size = 2587733 - -[packages.wheels.hashes] -sha256 = "90869072e50b7c8904fe1dd7810321ae901fd5637a6eec6646ed9c57f9eb1081" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/19/a2/5f4d305cbd4489d21570e5491ad5c483c478cdab032853e2125c280e3bd5/cython-3.3.0-cp39-abi3-win_arm64.whl" -upload-time = 2026-08-22 05:17:56+00:00 -size = 2608078 - -[packages.wheels.hashes] -sha256 = "dce56c26d388f00a19426371b6926bf2f77c5c03b71d5273e4556c68be98c2dd" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/bf/77/67b0b24e45073a699610e50f00c18474ff9b09ea29ecc95083bdf5e60acd/cython-3.3.0-py3-none-any.whl" -upload-time = 2026-08-22 05:16:36+00:00 -size = 1349151 - -[packages.wheels.hashes] -sha256 = "9b24b5c8cd536946b62086fcafee6d5509d3f549f72d553d2336af87ffbe0da1" - -[[packages]] -name = "distlib" -version = "0.4.4" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/68/bc/d36966de7304047a1001a7559fbf266c464bb5d8cc85464536ef8c9bcc32/distlib-0.4.4.tar.gz" -upload-time = 2026-10-16 13:56:34+00:00 -size = 621304 - -[packages.sdist.hashes] -sha256 = "ca4175b4ac7f71223cc30ed7248c45d2b38c2439d8ee6e498ab67ed63cf3508d" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/c1/2b/238f02a4927fa2e27aa995371d240a468b52e42eae44a678434aff736b38/distlib-0.4.4-py2.py3-none-any.whl" -upload-time = 2026-10-16 13:56:32+00:00 -size = 472099 - -[packages.wheels.hashes] -sha256 = "cf09d55b1e85a89576b036aeb71550bfd68320f3f21761ca329eadadad918638" - -[[packages]] -name = "dnspython" -version = "2.9.0" -marker = "\"release-attestations\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/ef/4a/50822184bd67cc6493f0fb6a880749158fcd31ab3fa07409acfd91f9fc85/dnspython-2.9.0.tar.gz" -upload-time = 2026-10-09 00:07:24+00:00 -size = 423560 - -[packages.sdist.hashes] -sha256 = "b44dc6b18f07a8b1c56676a19fbfdb5209415b046a9cece286baafa87ff3f7f1" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/10/02/cdcc9b7c051786a103c3b09e1003a82fa0c66bcb91ffbdabcfbf7b4163b9/dnspython-2.9.0-py3-none-any.whl" -upload-time = 2026-10-09 00:07:22+00:00 -size = 354822 - -[packages.wheels.hashes] -sha256 = "9a4aedb833c3c1b49214d04d44d3032ab7a9135f7c1d29a549b4ff78fd82fda9" - -[[packages]] -name = "docutils" -version = "0.23" -marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz" -upload-time = 2026-05-27 17:41:06+00:00 -size = 2303823 - -[packages.sdist.hashes] -sha256 = "746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl" -upload-time = 2026-05-27 17:40:58+00:00 -size = 634701 - -[packages.wheels.hashes] -sha256 = "25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea" - -[[packages]] -name = "email-validator" -version = "2.3.0" -marker = "\"release-attestations\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz" -upload-time = 2025-08-26 13:09:06+00:00 -size = 51238 - -[packages.sdist.hashes] -sha256 = "9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl" -upload-time = 2025-08-26 13:09:05+00:00 -size = 35604 - -[packages.wheels.hashes] -sha256 = "80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4" - -[[packages]] -name = "execnet" -version = "2.1.2" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz" -upload-time = 2025-11-12 09:56:37+00:00 -size = 166622 - -[packages.sdist.hashes] -sha256 = "63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl" -upload-time = 2025-11-12 09:56:36+00:00 -size = 40708 - -[packages.wheels.hashes] -sha256 = "67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec" - -[[packages]] -name = "filelock" -version = "4.2.1" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/d5/98/771d8ca889f220d7d7e352addf500666368f0fd081c6080a83767580c6e4/filelock-4.2.1.tar.gz" -upload-time = 2026-10-17 16:48:14+00:00 -size = 650997 - -[packages.sdist.hashes] -sha256 = "c4c937ccac2a813cee448e662a56366cfb374c0f8702e75f295c412cc1c5d5a3" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/28/00/db89d339bc4ecdc8087fa7b8607ba22ffc27bf96286f340c9620bea428b3/filelock-4.2.1-py3-none-any.whl" -upload-time = 2026-10-17 16:48:12+00:00 -size = 162455 - -[packages.wheels.hashes] -sha256 = "7be2662e9e826b9bba28afc0f843a123280d799a2ea44bb5d35ccec67905ae44" - -[[packages]] -name = "hatchling" -version = "1.32.4" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/f6/97/b5312f01a8c6daf729a9d272dd442e0c546dbcc630495788786c4b567ed0/hatchling-1.32.4.tar.gz" -upload-time = 2026-09-20 22:48:45+00:00 -size = 58065 - -[packages.sdist.hashes] -sha256 = "c4468f73144c054d2aab4ef0f0378c43b9878bf07f8ffd6b79690e970d375f07" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/5f/80/91f51f439c05d4ec4623c22928ce16a938d6d793bf709477830823497859/hatchling-1.32.4-py3-none-any.whl" -upload-time = 2026-09-20 22:48:44+00:00 -size = 78748 - -[packages.wheels.hashes] -sha256 = "08ecf7548fb48205e7f213d70c71e67b8271b7242093dc3f1da578b42c734a2c" - -[[packages]] -name = "id" -version = "1.6.1" -marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz" -upload-time = 2026-02-04 16:19:41+00:00 -size = 18088 - -[packages.sdist.hashes] -sha256 = "d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl" -upload-time = 2026-02-04 16:19:40+00:00 -size = 14689 - -[packages.wheels.hashes] -sha256 = "f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca" - -[[packages]] -name = "idna" -version = "3.20" -marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz" -upload-time = 2026-09-17 14:11:04+00:00 -size = 216463 - -[packages.sdist.hashes] -sha256 = "a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl" -upload-time = 2026-09-17 14:11:03+00:00 -size = 69583 - -[packages.wheels.hashes] -sha256 = "ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c" - -[[packages]] -name = "iniconfig" -version = "2.3.1" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz" -upload-time = 2026-10-06 22:48:38+00:00 -size = 21209 - -[packages.sdist.hashes] -sha256 = "67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl" -upload-time = 2026-10-06 22:48:36+00:00 -size = 7552 - -[packages.wheels.hashes] -sha256 = "9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7" - -[[packages]] -name = "installer" -version = "1.1.0" -marker = "\"lock-tools\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/56/d3/2fab33d15004d41ae8e7b5d3e310510f7f34a7e1dd032a3e60d8b38d8fba/installer-1.1.0.tar.gz" -upload-time = 2026-10-12 19:35:52+00:00 -size = 481257 - -[packages.sdist.hashes] -sha256 = "5adc0c01ce62d809989e246987cd68944fe5df8af562d0358f65b2ab15cb5de7" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d7/7a/63547a792a12148882205d79e07644091d5b7690f40c2bb9d41452a8bdc2/installer-1.1.0-py3-none-any.whl" -upload-time = 2026-10-12 19:35:51+00:00 -size = 464466 - -[packages.wheels.hashes] -sha256 = "f55f5728ff61d3dd54dbc5a397cf3f7307cb64b180c20b72afbebf01c9e2c8a0" - -[[packages]] -name = "jaraco-classes" -version = "3.4.0" -marker = "(\"test\" in dependency_groups and (platform_machine != 'ppc64le' and platform_machine != 's390x'))" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz" -upload-time = 2024-03-31 07:27:36+00:00 -size = 11780 - -[packages.sdist.hashes] -sha256 = "47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl" -upload-time = 2024-03-31 07:27:34+00:00 -size = 6777 - -[packages.wheels.hashes] -sha256 = "f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790" - -[[packages]] -name = "jaraco-context" -version = "6.1.2" -marker = "(\"test\" in dependency_groups and (platform_machine != 'ppc64le' and platform_machine != 's390x'))" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz" -upload-time = 2026-03-20 22:13:33+00:00 -size = 16801 - -[packages.sdist.hashes] -sha256 = "f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl" -upload-time = 2026-03-20 22:13:32+00:00 -size = 7871 - -[packages.wheels.hashes] -sha256 = "bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535" - -[[packages]] -name = "jaraco-functools" -version = "4.6.0" -marker = "(\"test\" in dependency_groups and (platform_machine != 'ppc64le' and platform_machine != 's390x'))" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz" -upload-time = 2026-07-14 01:28:02+00:00 -size = 20837 - -[packages.sdist.hashes] -sha256 = "880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl" -upload-time = 2026-07-14 01:28:01+00:00 -size = 11677 - -[packages.wheels.hashes] -sha256 = "99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30" - -[[packages]] -name = "jeepney" -version = "0.9.0" -marker = "(\"test\" in dependency_groups and (platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'))" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz" -upload-time = 2025-02-27 18:51:01+00:00 -size = 106758 - -[packages.sdist.hashes] -sha256 = "cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl" -upload-time = 2025-02-27 18:51:00+00:00 -size = 49010 - -[packages.wheels.hashes] -sha256 = "97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683" - -[[packages]] -name = "jinja2" -version = "3.1.6" -marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz" -upload-time = 2025-03-05 20:05:02+00:00 -size = 245115 - -[packages.sdist.hashes] -sha256 = "0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl" -upload-time = 2025-03-05 20:05:00+00:00 -size = 134899 - -[packages.wheels.hashes] -sha256 = "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" - -[[packages]] -name = "keyring" -version = "25.7.0" -marker = "(\"test\" in dependency_groups and (platform_machine != 'ppc64le' and platform_machine != 's390x'))" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz" -upload-time = 2025-11-16 16:26:09+00:00 -size = 63516 - -[packages.sdist.hashes] -sha256 = "fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl" -upload-time = 2025-11-16 16:26:08+00:00 -size = 39160 - -[packages.wheels.hashes] -sha256 = "be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f" - -[[packages]] -name = "markdown-it-py" -version = "4.2.0" -marker = "\"release-attestations\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz" -upload-time = 2026-05-07 12:08:28+00:00 -size = 82454 - -[packages.sdist.hashes] -sha256 = "04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl" -upload-time = 2026-05-07 12:08:27+00:00 -size = 91687 - -[packages.wheels.hashes] -sha256 = "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" - -[[packages]] -name = "markupsafe" -version = "3.0.4" -marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz" -upload-time = 2026-10-02 23:07:22+00:00 -size = 153777 - -[packages.sdist.hashes] -sha256 = "2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl" -upload-time = 2026-10-02 23:06:10+00:00 -size = 12791 - -[packages.wheels.hashes] -sha256 = "737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl" -upload-time = 2026-10-02 23:06:11+00:00 -size = 12527 - -[packages.wheels.hashes] -sha256 = "489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl" -upload-time = 2026-10-02 23:06:13+00:00 -size = 11602 - -[packages.wheels.hashes] -sha256 = "077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl" -upload-time = 2026-10-02 23:06:14+00:00 -size = 11817 - -[packages.wheels.hashes] -sha256 = "9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl" -upload-time = 2026-10-02 23:06:15+00:00 -size = 11446 - -[packages.wheels.hashes] -sha256 = "b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl" -upload-time = 2026-10-02 23:06:16+00:00 -size = 11719 - -[packages.wheels.hashes] -sha256 = "799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl" -upload-time = 2026-10-02 23:06:17+00:00 -size = 12075 - -[packages.wheels.hashes] -sha256 = "ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97" - -[[packages.wheels]] -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" -upload-time = 2026-10-02 23:06:18+00:00 -size = 24656 - -[packages.wheels.hashes] -sha256 = "4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b" - -[[packages.wheels]] -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" -upload-time = 2026-10-02 23:06:20+00:00 -size = 27737 - -[packages.wheels.hashes] -sha256 = "3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9" - -[[packages.wheels]] -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" -upload-time = 2026-10-02 23:06:21+00:00 -size = 25003 - -[packages.wheels.hashes] -sha256 = "04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653" - -[[packages.wheels]] -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" -upload-time = 2026-10-02 23:06:22+00:00 -size = 23111 - -[packages.wheels.hashes] -sha256 = "925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl" -upload-time = 2026-10-02 23:06:23+00:00 -size = 22403 - -[packages.wheels.hashes] -sha256 = "f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl" -upload-time = 2026-10-02 23:06:25+00:00 -size = 24083 - -[packages.wheels.hashes] -sha256 = "e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl" -upload-time = 2026-10-02 23:06:26+00:00 -size = 22931 - -[packages.wheels.hashes] -sha256 = "55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl" -upload-time = 2026-10-02 23:06:27+00:00 -size = 24299 - -[packages.wheels.hashes] -sha256 = "2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl" -upload-time = 2026-10-02 23:06:29+00:00 -size = 21948 - -[packages.wheels.hashes] -sha256 = "94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl" -upload-time = 2026-10-02 23:06:30+00:00 -size = 23150 - -[packages.wheels.hashes] -sha256 = "2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl" -upload-time = 2026-10-02 23:06:31+00:00 -size = 14269 - -[packages.wheels.hashes] -sha256 = "03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl" -upload-time = 2026-10-02 23:06:33+00:00 -size = 14496 - -[packages.wheels.hashes] -sha256 = "d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl" -upload-time = 2026-10-02 23:06:34+00:00 -size = 14361 - -[packages.wheels.hashes] -sha256 = "353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl" -upload-time = 2026-10-02 23:06:35+00:00 -size = 11760 - -[packages.wheels.hashes] -sha256 = "c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl" -upload-time = 2026-10-02 23:06:37+00:00 -size = 12108 - -[packages.wheels.hashes] -sha256 = "1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99" - -[[packages.wheels]] -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" -upload-time = 2026-10-02 23:06:38+00:00 -size = 25762 - -[packages.wheels.hashes] -sha256 = "02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002" - -[[packages.wheels]] -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" -upload-time = 2026-10-02 23:06:39+00:00 -size = 28400 - -[packages.wheels.hashes] -sha256 = "05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e" - -[[packages.wheels]] -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" -upload-time = 2026-10-02 23:06:41+00:00 -size = 25946 - -[packages.wheels.hashes] -sha256 = "be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c" - -[[packages.wheels]] -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" -upload-time = 2026-10-02 23:06:42+00:00 -size = 23634 - -[packages.wheels.hashes] -sha256 = "26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl" -upload-time = 2026-10-02 23:06:44+00:00 -size = 23488 - -[packages.wheels.hashes] -sha256 = "f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl" -upload-time = 2026-10-02 23:06:45+00:00 -size = 25037 - -[packages.wheels.hashes] -sha256 = "436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl" -upload-time = 2026-10-02 23:06:47+00:00 -size = 23543 - -[packages.wheels.hashes] -sha256 = "4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl" -upload-time = 2026-10-02 23:06:48+00:00 -size = 25282 - -[packages.wheels.hashes] -sha256 = "da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl" -upload-time = 2026-10-02 23:06:50+00:00 -size = 22993 - -[packages.wheels.hashes] -sha256 = "aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl" -upload-time = 2026-10-02 23:06:51+00:00 -size = 23712 - -[packages.wheels.hashes] -sha256 = "b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl" -upload-time = 2026-10-02 23:06:53+00:00 -size = 14300 - -[packages.wheels.hashes] -sha256 = "e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl" -upload-time = 2026-10-02 23:06:54+00:00 -size = 14522 - -[packages.wheels.hashes] -sha256 = "4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl" -upload-time = 2026-10-02 23:06:56+00:00 -size = 14417 - -[packages.wheels.hashes] -sha256 = "39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba" - -[[packages]] -name = "mdformat" -version = "1.0.0" -marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz" -upload-time = 2025-10-16 12:05:03+00:00 -size = 56953 - -[packages.sdist.hashes] -sha256 = "4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl" -upload-time = 2025-10-16 12:05:02+00:00 -size = 53288 - -[packages.wheels.hashes] -sha256 = "bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b" - -[[packages]] -name = "mdformat-gfm" -version = "1.0.0" -marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/56/6f/a626ebb142a290474401b67e2d61e73ce096bf7798ee22dfe6270f924b3f/mdformat_gfm-1.0.0.tar.gz" -upload-time = 2025-10-16 09:12:22+00:00 -size = 10112 - -[packages.sdist.hashes] -sha256 = "d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e6/18/6bc2189b744dd383cad03764f41f30352b1278d2205096f77a29c0b327ad/mdformat_gfm-1.0.0-py3-none-any.whl" -upload-time = 2025-10-16 09:12:21+00:00 -size = 10970 - -[packages.wheels.hashes] -sha256 = "7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005" - -[[packages]] -name = "mdit-py-plugins" -version = "0.6.1" -marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz" -upload-time = 2026-05-13 09:03:38+00:00 -size = 56114 - -[packages.sdist.hashes] -sha256 = "a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl" -upload-time = 2026-05-13 09:03:37+00:00 -size = 66663 - -[packages.wheels.hashes] -sha256 = "214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d" - -[[packages]] -name = "mdurl" -version = "0.1.2" -marker = "\"release-attestations\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz" -upload-time = 2022-08-14 12:40:10+00:00 -size = 8729 - -[packages.sdist.hashes] -sha256 = "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl" -upload-time = 2022-08-14 12:40:09+00:00 -size = 9979 - -[packages.wheels.hashes] -sha256 = "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" - -[[packages]] -name = "more-itertools" -version = "11.2.1" -marker = "(\"test\" in dependency_groups and (platform_machine != 'ppc64le' and platform_machine != 's390x'))" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/52/b8/c63995bebbb9ac9c059350e0fb8c82c2420c85aeca7d34e40aecf3df9281/more_itertools-11.2.1.tar.gz" -upload-time = 2026-10-14 18:50:30+00:00 -size = 154133 - -[packages.sdist.hashes] -sha256 = "cbf08fd0af284dc69718b9b76a8e6203df624d70209ea511e4219d350c856f63" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/5d/57/3c4b1c6f2e0cc29ba023802458ea3d2a996f87085559e4a5f2192d549532/more_itertools-11.2.1-py3-none-any.whl" -upload-time = 2026-10-14 18:50:29+00:00 -size = 74405 - -[packages.wheels.hashes] -sha256 = "35a7377edd1dd6608dcb2cdf534ded55ea32d49448875ad042cd3f879fb1ded0" - -[[packages]] -name = "nab" -version = "0.0.22" -marker = "\"lock-tools\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/25/3d/e4c8f4b2890efbf2fa6eef82acf57ecc0ef39b0a1e6843d306d2053a107c/nab-0.0.22.tar.gz" -upload-time = 2026-10-18 05:45:08+00:00 -size = 286128 - -[packages.sdist.hashes] -sha256 = "a0da54ee96bf1eb1997581566620c250c8a3557139e9467ccaced2a12e9e71ac" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/56/4b/77baa0ddb0b7578b6475b2b578fa4579207b0f97e0b3b80480e01dcb3d6f/nab-0.0.22-py3-none-any.whl" -upload-time = 2026-10-18 05:45:06+00:00 -size = 137802 - -[packages.wheels.hashes] -sha256 = "f62fcf75b28786fbf58627f6d7eaa735daefbc7f736715403bc4357acb57e7bb" - -[[packages]] -name = "nab-index" -version = "0.0.22" -marker = "\"lock-tools\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/2a/83/8d2483239b7dc5ac8d9f758db1cc19d4129d0143d34c2e1ab8cd48ea71fb/nab_index-0.0.22.tar.gz" -upload-time = 2026-10-18 05:44:43+00:00 -size = 110621 - -[packages.sdist.hashes] -sha256 = "5900ea5ad298512379cfddff1a73a544590f0b04620ef58ff345f99a7c59688e" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/c0/58/c06ada29934a78479b989675829f26083972f38bf1e8a2503e01101cb970/nab_index-0.0.22-py3-none-any.whl" -upload-time = 2026-10-18 05:44:42+00:00 -size = 88373 - -[packages.wheels.hashes] -sha256 = "c89ffa607dfe4246dc59e9f5236d54413da3d310183ffa3298ba51a48e7c2ec7" - -[[packages]] -name = "nab-markersets" -version = "0.0.22" -marker = "\"lock-tools\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/fc/b7/3997893dfdada9998d4dc786c10445a1361e197b4aed54ddd866c84dd538/nab_markersets-0.0.22.tar.gz" -upload-time = 2026-10-18 05:44:19+00:00 -size = 109258 - -[packages.sdist.hashes] -sha256 = "38212664f517f75d798808ecfe902bfd11fef340042ab0203100c7e06e78eea5" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/5e/9e/9744adff3a66633a811f8253faaa8b91a447c99199edba16f2790e3647ea/nab_markersets-0.0.22-py3-none-any.whl" -upload-time = 2026-10-18 05:44:18+00:00 -size = 44360 - -[packages.wheels.hashes] -sha256 = "ad268dcece034495b2ea31a70250cd43cc4f6123039a548762c7ec8826e5d3bd" - -[[packages]] -name = "nab-project" -version = "0.0.22" -marker = "\"lock-tools\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/65/84/f5197d4044ceda9c6ee3e7aefce154ab616fec632fa1d89cdfaacc665c07/nab_project-0.0.22.tar.gz" -upload-time = 2026-10-18 05:44:55+00:00 -size = 774409 - -[packages.sdist.hashes] -sha256 = "5f7a1dee5b618149267b2b6233c3a220e45cd6f97bd5ec7f3f48fa7570e9f053" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/de/c9/62affa4df6cabce48751307c4c3adfbaa8c54e402d464d015897a16a6dba/nab_project-0.0.22-py3-none-any.whl" -upload-time = 2026-10-18 05:44:53+00:00 -size = 148262 - -[packages.wheels.hashes] -sha256 = "cb09fa143105fd99a60609b3ff4830924b0d4eaf5abad48678467ec4b73e577f" - -[[packages]] -name = "nab-provider" -version = "0.0.22" -marker = "\"lock-tools\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/94/0d/64fbadae562fefc00257c4f5ec988171d6e6b3ea4363f1da2e8886e80228/nab_provider-0.0.22.tar.gz" -upload-time = 2026-10-18 05:44:31+00:00 -size = 409367 - -[packages.sdist.hashes] -sha256 = "3f129ab7394bf5eb031152c3535ee74073e2a6b6ae081fb7f5a4d269fcf5f8a0" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/30/67/32bda18d34f0f8ec098f8e4a0f792164ef1a4b2aa61d435d20f4645572af/nab_provider-0.0.22-py3-none-any.whl" -upload-time = 2026-10-18 05:44:29+00:00 -size = 339242 - -[packages.wheels.hashes] -sha256 = "1450c9dc58b23da1a7bde7fdb43a72b043327bd0e3ef20435254ffcb8b7eae6b" - -[[packages]] -name = "nab-resolver" -version = "0.0.22" -marker = "\"lock-tools\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/84/bc/dcbfe5163538b1cc8421797e3f8ff77d79321cbc9943af8065e2c8ae1c09/nab_resolver-0.0.22.tar.gz" -upload-time = 2026-10-18 05:44:08+00:00 -size = 167648 - -[packages.sdist.hashes] -sha256 = "e005b225213e032858cf4aef3850961aaa1d4ea521428da731f33175ebd3b05f" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/22/a8/6e30b25ae12e0c662dc9b181a7f32fa8cd22f26016f12dc1d10176f62bba/nab_resolver-0.0.22-py3-none-any.whl" -upload-time = 2026-10-18 05:44:06+00:00 -size = 58597 - -[packages.wheels.hashes] -sha256 = "6f77daa98279ef0eedb9e8f2248aee4c50e01b92b470b68886995e5056d79971" - -[[packages]] -name = "nh3" -version = "0.3.7" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/18/2f/022b27146d52d24b1b353b003359134788ecbcd6fcdf6283adbd57c0fbc8/nh3-0.3.7.tar.gz" -upload-time = 2026-08-23 14:26:30+00:00 -size = 25662 - -[packages.sdist.hashes] -sha256 = "71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/94/0d/c257754bf57f829f307aa226bbe136d3a1356b5a0d08324c7b6bd2a8aacd/nh3-0.3.7-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl" -upload-time = 2026-08-23 14:26:09+00:00 -size = 1493959 - -[packages.wheels.hashes] -sha256 = "6c3aa50eb26e9228238271db9f983cbc3b006dfbfeca2d4dc34c33ddc6ac5ea5" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/07/42/a687e7091928806e514f89fa2666f25ec9bfe0a902fc4402b25e51ce408b/nh3-0.3.7-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" -upload-time = 2026-08-23 14:26:10+00:00 -size = 859615 - -[packages.wheels.hashes] -sha256 = "f266d3f1b3647449923a8e406524632220dd5d8b647078dfe45b885d33d10479" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/85/05/b0e6bef633549a23347d5462aa288fcc42381e7918482062ca3cb456242a/nh3-0.3.7-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl" -upload-time = 2026-08-23 14:26:12+00:00 -size = 839872 - -[packages.wheels.hashes] -sha256 = "e8fd1ab205258b29254f72db377d99e2c96aa7653ef3b015ccab0420b094b506" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/17/40/2a0921d45b20828708bcb56887e47dcf8cae13818de5bf9a01308d348712/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl" -upload-time = 2026-08-23 14:26:13+00:00 -size = 1091325 - -[packages.wheels.hashes] -sha256 = "19f288c938ec6eef1f5d2c6cab47838e71fef8097e1c1233802be5a6230ba086" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e4/d1/9d70e0e418a48280ec0ddc6c1b08b4b1136ebcc31a1625e57ff5c665fa51/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" -upload-time = 2026-08-23 14:26:14+00:00 -size = 1042482 - -[packages.wheels.hashes] -sha256 = "de2b2aab32ea303405debefdcfc58043d3e635fa3f67b9eb140d2b0e0c0d2563" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/93/a7/02dd159d4e71f98607d8d4249cddb7561e77be1a8e4dec77d76e1b68fc99/nh3-0.3.7-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl" -upload-time = 2026-08-23 14:26:16+00:00 -size = 946868 - -[packages.wheels.hashes] -sha256 = "9b7279d43323a25225df23576af6594a16693f61431170848b8b2ac21ad4f174" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a6/ed/c5510c615dce55b6fcc364aa1838142f938beed64f5e4927490dfcaf4405/nh3-0.3.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" -upload-time = 2026-08-23 14:26:17+00:00 -size = 832161 - -[packages.wheels.hashes] -sha256 = "70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/7b/e3/3212c1a5b5745245d7f18885207bbddb34c56075f34dd682bd539aad55cc/nh3-0.3.7-cp38-abi3-manylinux_2_31_riscv64.whl" -upload-time = 2026-08-23 14:26:18+00:00 -size = 849791 - -[packages.wheels.hashes] -sha256 = "5ffdfcb9a686ffb12765376bcfb6b5b55728516d3c0ee317d29982381ded3df8" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/20/64/9e36594efad6c290de4240d02cb2bd80c339a4ab1c4de66e599ffa6d9d81/nh3-0.3.7-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl" -upload-time = 2026-08-23 14:26:19+00:00 -size = 875473 - -[packages.wheels.hashes] -sha256 = "bc42bb1193c1e28a1e74c2cabaca178e118a7103e8832699fef8a2b3e2496493" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/00/0c/1a8985fd43fea5530c0ac890b6f0b423770ee72f111b70b7a77f2dec243a/nh3-0.3.7-cp38-abi3-musllinux_1_2_aarch64.whl" -upload-time = 2026-08-23 14:26:21+00:00 -size = 1036463 - -[packages.wheels.hashes] -sha256 = "d56e76bd3cadb09b6b0cef364850811663734b348a25f5f587a2819c495367bd" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b2/5d/891e533b716cf00df76ad0ba6485dcfd14d59a6430a3cc99057c4c04004e/nh3-0.3.7-cp38-abi3-musllinux_1_2_armv7l.whl" -upload-time = 2026-08-23 14:26:22+00:00 -size = 1116029 - -[packages.wheels.hashes] -sha256 = "fd4a70efb45d5372174f718878eb7a35c12677626a63b2f103b23b833457dcac" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/42/e5/ae8c0782fce74fb6fcf7234bb3d4017f37ce181b4f9d29369eab21c50a04/nh3-0.3.7-cp38-abi3-musllinux_1_2_i686.whl" -upload-time = 2026-08-23 14:26:24+00:00 -size = 1076589 - -[packages.wheels.hashes] -sha256 = "15f5fbf090f5c88d61c820e1fc1fceecb6520cca9fe85649c06b57ef9dc9ff62" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/26/a4/c3423351e8d864ad756e85e15f0c01433361f14d34e4ed156482c0518f2a/nh3-0.3.7-cp38-abi3-musllinux_1_2_x86_64.whl" -upload-time = 2026-08-23 14:26:25+00:00 -size = 1058871 - -[packages.wheels.hashes] -sha256 = "6698a822132beedab80f131c08d8d0ac5a178ddeb488d02ca4b67716ecfac7af" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/4b/6a/478f153f1d7c0baaa3d1e8bb5fdcee3a6235f90fe44ea969a9d4e2b8c47a/nh3-0.3.7-cp38-abi3-win32.whl" -upload-time = 2026-08-23 14:26:26+00:00 -size = 630729 - -[packages.wheels.hashes] -sha256 = "6e4280115d44c3b278eef712a86748c1a723105cd79feec46952383117ab4e59" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b4/b9/34433ccb1f0fe6968dabbb7d4bf5721c6221878ef07832748c06655a6a80/nh3-0.3.7-cp38-abi3-win_amd64.whl" -upload-time = 2026-08-23 14:26:28+00:00 -size = 644462 - -[packages.wheels.hashes] -sha256 = "618e3059caf41ccdf5dcccb3fa9df4cf6e4efe23d1382a8bbfca272a8a4f8bfc" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f9/70/e140dffff6e808dc6343598df76e7e2407fd0f581de3524c75fba2e0cf24/nh3-0.3.7-cp38-abi3-win_arm64.whl" -upload-time = 2026-08-23 14:26:29+00:00 -size = 621867 - -[packages.wheels.hashes] -sha256 = "f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a" - -[[packages]] -name = "packaging" -version = "26.3" -marker = "\"lock-reader\" in dependency_groups or \"lock-tools\" in dependency_groups or \"release-attestations\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz" -upload-time = 2026-08-04 18:15:28+00:00 -size = 313412 - -[packages.sdist.hashes] -sha256 = "94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl" -upload-time = 2026-08-04 18:15:27+00:00 -size = 129956 - -[packages.wheels.hashes] -sha256 = "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" - -[[packages]] -name = "pathspec" -version = "1.1.1" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz" -upload-time = 2026-04-27 01:46:08+00:00 -size = 135180 - -[packages.sdist.hashes] -sha256 = "17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl" -upload-time = 2026-04-27 01:46:07+00:00 -size = 57328 - -[packages.wheels.hashes] -sha256 = "a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189" - -[[packages]] -name = "platformdirs" -version = "4.13.4" -marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/b7/89/dfd269de936e76319cf27787d9eaaca513491f635d49b0fbe7cdb6d3bf6f/platformdirs-4.13.4.tar.gz" -upload-time = 2026-10-17 19:31:53+00:00 -size = 62364 - -[packages.sdist.hashes] -sha256 = "dd96496961684a7f42041923f842ec7494e09f5e87aded99117e1109cd40570e" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/58/51/5614d0537dd830ca64d95a2f254bd66bdf3ac61d41cffcccaec62121fcfd/platformdirs-4.13.4-py3-none-any.whl" -upload-time = 2026-10-17 19:31:52+00:00 -size = 32782 - -[packages.wheels.hashes] -sha256 = "502ce08e9d36cf90579ee44e1e764838842fa658e4975ef47344fd053011d2c8" - -[[packages]] -name = "pluggy" -version = "1.7.0" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz" -upload-time = 2026-10-15 09:50:58+00:00 -size = 123304 - -[packages.sdist.hashes] -sha256 = "d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl" -upload-time = 2026-10-15 09:50:56+00:00 -size = 27082 - -[packages.wheels.hashes] -sha256 = "7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec" - -[[packages]] -name = "prek" -version = "0.5.5" -marker = "\"release-check\" in dependency_groups or \"release-hook-check\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-shell\" in dependency_groups or \"release-validate\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/9b/7b/11538b8e6cd7e269261feae62a2d08818fdcf8f43c3b1a253cec01a5488d/prek-0.5.5.tar.gz" -upload-time = 2026-10-05 06:08:59+00:00 -size = 570428 - -[packages.sdist.hashes] -sha256 = "16bf596768bb754779c7d42317e9ca16ac609c0cce7d13bae0829944ab40a26a" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/2e/77/189e554ea92baf6c1d33733b3e9d1ed819ab7d77e57eb2e3ead0bb1d8060/prek-0.5.5-py3-none-macosx_10_12_x86_64.whl" -upload-time = 2026-10-05 06:08:39+00:00 -size = 6038669 - -[packages.wheels.hashes] -sha256 = "50fbceb0f728b504023f6e6c9037ac0656436949d94f7a85bac5542e6c1a4a75" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/63/ea/fc857c6b3f1e3fdb4d81bdca2051076de82e986cb1455a01b351ac7a5cd1/prek-0.5.5-py3-none-macosx_11_0_arm64.whl" -upload-time = 2026-10-05 06:08:41+00:00 -size = 5548955 - -[packages.wheels.hashes] -sha256 = "2f610927b95e84b28173a4c810892a7b5d5bb55bee792ddf97178fa97654633e" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/47/c3/2bab54f6393fb9f247225681d476086e24fa6242f329e9ca1e5bd638ec50/prek-0.5.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl" -upload-time = 2026-10-05 06:08:44+00:00 -size = 5848228 - -[packages.wheels.hashes] -sha256 = "120ef7d4b6bb58155e74827743c266e48501efb0e4c37ff726b14fe6e86a126b" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/aa/f9/1279e1c83819de5904db1a09f4afc589932a2e62c7063713b1fe3a54ee46/prek-0.5.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" -upload-time = 2026-10-05 06:08:46+00:00 -size = 6371296 - -[packages.wheels.hashes] -sha256 = "2f6c453a12c21757b23bbbc97415aef78527841ad3f9e61f52cb35cf012f93e5" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/93/fc/e566be5919eaffef4cc22488f16e4bbfd08961734bd67c06f85d405e4cbc/prek-0.5.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" -upload-time = 2026-10-05 06:08:48+00:00 -size = 6269922 - -[packages.wheels.hashes] -sha256 = "73d509c0d9556171de2502bada4962be7a25871e7b23e782549074e1416dc850" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d2/39/a48d8e6203055bff7bb734b1f2f9aede4a4539bee6ab4c9273dbc01fa546/prek-0.5.5-py3-none-manylinux_2_28_aarch64.whl" -upload-time = 2026-10-05 06:08:50+00:00 -size = 5861518 - -[packages.wheels.hashes] -sha256 = "76c867fdbd4e4f19e6b6b2988bd1e1407d1324170ebcf1428e4e34f8f0751a99" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b7/03/690fba9543033e25ea2e7b2ae1cca1ae8d8c2e32295f0d66f4b50934b8c7/prek-0.5.5-py3-none-musllinux_1_1_x86_64.whl" -upload-time = 2026-10-05 06:08:52+00:00 -size = 6383685 - -[packages.wheels.hashes] -sha256 = "c69f60c2b3b4d481e38d0eab3b97ac05ec05d7648ae690925ae95ca9d22c7643" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/8c/04/1e9ccf3a61a3bfd4107b83973f7afde9177b6ff8f2d5d4b00125e77c2100/prek-0.5.5-py3-none-win_amd64.whl" -upload-time = 2026-10-05 06:08:55+00:00 -size = 5784759 - -[packages.wheels.hashes] -sha256 = "15bff9039349466ea9bf6b0c173b822ff08f8ae4cfbfdea782492e519c06d874" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/41/d9/bb6811e438d3c74d26cb5d72e6343606e0a7a6283fee87521189f4ba1191/prek-0.5.5-py3-none-win_arm64.whl" -upload-time = 2026-10-05 06:08:57+00:00 -size = 5517355 - -[packages.wheels.hashes] -sha256 = "3db3191d2509830c75438e20956a47c2259867d18a4106064ed75088b968a0c6" - -[[packages]] -name = "pyasn1" -version = "0.6.4" -marker = "\"release-attestations\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz" -upload-time = 2026-07-09 01:12:33+00:00 -size = 151262 - -[packages.sdist.hashes] -sha256 = "9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl" -upload-time = 2026-07-09 01:12:32+00:00 -size = 84410 - -[packages.wheels.hashes] -sha256 = "deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b" - -[[packages]] -name = "pycparser" -version = "3.11" -marker = "(\"release-attestations\" in dependency_groups and (implementation_name != 'PyPy' and platform_python_implementation != 'PyPy')) or (\"test\" in dependency_groups and (implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'))" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz" -upload-time = 2026-10-09 12:56:59+00:00 -size = 113796 - -[packages.sdist.hashes] -sha256 = "d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl" -upload-time = 2026-10-09 12:56:58+00:00 -size = 51178 - -[packages.wheels.hashes] -sha256 = "51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80" - -[[packages]] -name = "pydantic" -version = "2.14.1" -marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz" -upload-time = 2026-10-11 18:37:55+00:00 -size = 900362 - -[packages.sdist.hashes] -sha256 = "94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl" -upload-time = 2026-10-11 18:37:53+00:00 -size = 488106 - -[packages.wheels.hashes] -sha256 = "9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454" - -[[packages]] -name = "pydantic-core" -version = "2.50.1" -marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz" -upload-time = 2026-10-11 18:35:44+00:00 -size = 513515 - -[packages.sdist.hashes] -sha256 = "e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl" -upload-time = 2026-10-11 18:34:04+00:00 -size = 2224599 - -[packages.wheels.hashes] -sha256 = "8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl" -upload-time = 2026-10-11 18:34:05+00:00 -size = 2024191 - -[packages.wheels.hashes] -sha256 = "84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" -upload-time = 2026-10-11 18:34:07+00:00 -size = 2048252 - -[packages.wheels.hashes] -sha256 = "23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl" -upload-time = 2026-10-11 18:34:09+00:00 -size = 2173376 - -[packages.wheels.hashes] -sha256 = "3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" -upload-time = 2026-10-11 18:34:11+00:00 -size = 2364559 - -[packages.wheels.hashes] -sha256 = "c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl" -upload-time = 2026-10-11 18:34:13+00:00 -size = 2444697 - -[packages.wheels.hashes] -sha256 = "ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831" - -[[packages.wheels]] -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" -upload-time = 2026-10-11 18:34:15+00:00 -size = 2159961 - -[packages.wheels.hashes] -sha256 = "7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl" -upload-time = 2026-10-11 18:34:17+00:00 -size = 2234581 - -[packages.wheels.hashes] -sha256 = "d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl" -upload-time = 2026-10-11 18:34:19+00:00 -size = 2244090 - -[packages.wheels.hashes] -sha256 = "40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl" -upload-time = 2026-10-11 18:34:21+00:00 -size = 2277948 - -[packages.wheels.hashes] -sha256 = "d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl" -upload-time = 2026-10-11 18:34:23+00:00 -size = 2449688 - -[packages.wheels.hashes] -sha256 = "84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl" -upload-time = 2026-10-11 18:34:25+00:00 -size = 2479286 - -[packages.wheels.hashes] -sha256 = "ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl" -upload-time = 2026-10-11 18:34:27+00:00 -size = 2103683 - -[packages.wheels.hashes] -sha256 = "bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl" -upload-time = 2026-10-11 18:34:29+00:00 -size = 2171681 - -[packages.wheels.hashes] -sha256 = "983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl" -upload-time = 2026-10-11 18:34:31+00:00 -size = 2156251 - -[packages.wheels.hashes] -sha256 = "94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl" -upload-time = 2026-10-11 18:34:33+00:00 -size = 2234363 - -[packages.wheels.hashes] -sha256 = "4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl" -upload-time = 2026-10-11 18:34:36+00:00 -size = 2000972 - -[packages.wheels.hashes] -sha256 = "efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" -upload-time = 2026-10-11 18:34:37+00:00 -size = 2022925 - -[packages.wheels.hashes] -sha256 = "bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl" -upload-time = 2026-10-11 18:34:39+00:00 -size = 2192867 - -[packages.wheels.hashes] -sha256 = "5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" -upload-time = 2026-10-11 18:34:42+00:00 -size = 2388687 - -[packages.wheels.hashes] -sha256 = "adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl" -upload-time = 2026-10-11 18:34:44+00:00 -size = 2464415 - -[packages.wheels.hashes] -sha256 = "2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a" - -[[packages.wheels]] -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" -upload-time = 2026-10-11 18:34:46+00:00 -size = 2150823 - -[packages.wheels.hashes] -sha256 = "23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl" -upload-time = 2026-10-11 18:34:48+00:00 -size = 2245806 - -[packages.wheels.hashes] -sha256 = "f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl" -upload-time = 2026-10-11 18:34:50+00:00 -size = 2264160 - -[packages.wheels.hashes] -sha256 = "455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl" -upload-time = 2026-10-11 18:34:52+00:00 -size = 2294031 - -[packages.wheels.hashes] -sha256 = "1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl" -upload-time = 2026-10-11 18:34:54+00:00 -size = 2468423 - -[packages.wheels.hashes] -sha256 = "d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl" -upload-time = 2026-10-11 18:34:57+00:00 -size = 2512598 - -[packages.wheels.hashes] -sha256 = "009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl" -upload-time = 2026-10-11 18:34:59+00:00 -size = 2098535 - -[packages.wheels.hashes] -sha256 = "3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl" -upload-time = 2026-10-11 18:35:01+00:00 -size = 2148645 - -[packages.wheels.hashes] -sha256 = "1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl" -upload-time = 2026-10-11 18:35:04+00:00 -size = 2175297 - -[packages.wheels.hashes] -sha256 = "c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44" - -[[packages]] -name = "pygments" -version = "2.21.0" -marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz" -upload-time = 2026-08-17 08:02:48+00:00 -size = 5005329 - -[packages.sdist.hashes] -sha256 = "610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl" -upload-time = 2026-08-17 08:02:44+00:00 -size = 1250147 - -[packages.wheels.hashes] -sha256 = "2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9" - -[[packages]] -name = "pyjwt" -version = "2.15.1" -marker = "\"release-attestations\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz" -upload-time = 2026-09-28 18:40:42+00:00 -size = 121252 - -[packages.sdist.hashes] -sha256 = "4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl" -upload-time = 2026-09-28 18:40:41+00:00 -size = 33860 - -[packages.wheels.hashes] -sha256 = "42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193" - -[[packages]] -name = "pyopenssl" -version = "26.4.0" -marker = "\"release-attestations\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz" -upload-time = 2026-08-01 19:50:50+00:00 -size = 182046 - -[packages.sdist.hashes] -sha256 = "28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl" -upload-time = 2026-08-01 19:50:48+00:00 -size = 56026 - -[packages.wheels.hashes] -sha256 = "f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c" - -[[packages]] -name = "pypi-attestations" -version = "0.0.30" -marker = "\"release-attestations\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/97/1e/eb8a0233cdcaf01acb18076ac8b8a057714177b3717baf26b78a11b8d929/pypi_attestations-0.0.30.tar.gz" -upload-time = 2026-07-28 14:08:35+00:00 -size = 139108 - -[packages.sdist.hashes] -sha256 = "14ff13c76bbef02a483c2b77532da02bfd746b5141b881f355e06b9807434423" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/5f/24/e59078318b5e2bca59be5a21957d70e082e6eb4f478adffe373f3b74daf4/pypi_attestations-0.0.30-py3-none-any.whl" -upload-time = 2026-07-28 14:08:33+00:00 -size = 23015 - -[packages.wheels.hashes] -sha256 = "b3a9c53f6cb89e5e7b5b70e6cfca97cfc66008c1ed54087355e06e40071cef21" - -[[packages]] -name = "pyproject-api" -version = "1.12.0" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/9a/c6/04bb77bccfa36aabc783a31e335b5441829f5ec9ff16e313b5b7022d81a8/pyproject_api-1.12.0.tar.gz" -upload-time = 2026-10-18 07:00:59+00:00 -size = 28058 - -[packages.sdist.hashes] -sha256 = "2e83226079dc446a8e2357def7622c01c733284e39afc8e332d9f722fe0c2beb" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b9/b6/ce66a85089985da9b15b0e0e0d668c4b26c994f619e28a11d609454f3d70/pyproject_api-1.12.0-py3-none-any.whl" -upload-time = 2026-10-18 07:00:58+00:00 -size = 14055 - -[packages.wheels.hashes] -sha256 = "1d3cbf37ac4c3a40940e09cbd4374608d25cdd89e6019f7e06fc285afd3ab467" - -[[packages]] -name = "pyproject-hooks" -version = "1.3.3" -marker = "\"lock-tools\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/6d/5d/f2ddeef4a855a102aaae5e97826a0260007522ab504421b75addfdb1517c/pyproject_hooks-1.3.3.tar.gz" -upload-time = 2026-09-16 08:58:03+00:00 -size = 21013 - -[packages.sdist.hashes] -sha256 = "defda19b854fa0d3bd4f76ea4ddcba8abd7dcfcdd585a6690ade050744fc5f43" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/85/11/044d1ae1b4ec0d7af88ee5bc91e081be1022533032b906a7bdabdbb60977/pyproject_hooks-1.3.3-py3-none-any.whl" -upload-time = 2026-09-16 08:58:02+00:00 -size = 10724 - -[packages.wheels.hashes] -sha256 = "5fc53fdac9f7bd63fbcdc868fb5f90b4784d78a53a3d3388cd738b807441a20b" - -[[packages]] -name = "pytest" -version = "9.1.1" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz" -upload-time = 2026-06-19 10:58:32+00:00 -size = 1636369 - -[packages.sdist.hashes] -sha256 = "1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl" -upload-time = 2026-06-19 10:58:31+00:00 -size = 386536 - -[packages.wheels.hashes] -sha256 = "37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" - -[[packages]] -name = "pytest-httpserver" -version = "1.2.0" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/37/ff/81c955e8fbb0323e817d178dd653de90c81896e819a5df65c1e1c327155a/pytest_httpserver-1.2.0.tar.gz" -upload-time = 2026-10-05 19:00:25+00:00 -size = 71860 - -[packages.sdist.hashes] -sha256 = "d07744ce2efaafa0575ed3081d416b999c773b09e84aa95065138bedc3e12352" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/8d/34/aa11718873f8cd3d9b3c58938e537af0c2d9cb108160357f5db2af6ddd66/pytest_httpserver-1.2.0-py3-none-any.whl" -upload-time = 2026-10-05 19:00:26+00:00 -size = 23392 - -[packages.wheels.hashes] -sha256 = "749937892dbf909c8b703503b491be66fc24073e7988bf9b4d783af4f33da942" - -[[packages]] -name = "pytest-mock" -version = "3.17.0" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/97/c2/28b9f5b955ff2bcd4727a6f768e2ca9b586cd7c37b95ea5f7b9b873ac7b3/pytest_mock-3.17.0.tar.gz" -upload-time = 2026-10-17 16:21:42+00:00 -size = 43064 - -[packages.sdist.hashes] -sha256 = "e36576e0985e01027e999891e7a966c67822d40c89ef1b4c8b6e22737e3590b4" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e8/ea/47fbdc8f37410956fd445720e927589b1e2a47772c5f28109f0ce2fa26d9/pytest_mock-3.17.0-py3-none-any.whl" -upload-time = 2026-10-17 16:21:41+00:00 -size = 11248 - -[packages.wheels.hashes] -sha256 = "1b82aa4b37137248bd687055e109723debeb07b489de1565297dc9589c5f2d6b" - -[[packages]] -name = "pytest-xdist" -version = "3.8.0" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz" -upload-time = 2025-07-01 13:30:59+00:00 -size = 88069 - -[packages.sdist.hashes] -sha256 = "7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl" -upload-time = 2025-07-01 13:30:56+00:00 -size = 46396 - -[packages.wheels.hashes] -sha256 = "202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88" - -[[packages]] -name = "python-discovery" -version = "1.6.3" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/fc/54/fad125f5c48f7821644ee6f223ebfcedc4e23597c1a16eeb13d33aa4a91a/python_discovery-1.6.3.tar.gz" -upload-time = 2026-10-16 03:54:29+00:00 -size = 88998 - -[packages.sdist.hashes] -sha256 = "a62b301d96cf5489cb96ab023b32e068421867fdd5df2e42eece877869047fa2" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/de/ec/643826e4d69b7bdce4213f221e8f5ed06d0a9dd69fa9b33342578d2cc8c8/python_discovery-1.6.3-py3-none-any.whl" -upload-time = 2026-10-16 03:54:28+00:00 -size = 39386 - -[packages.wheels.hashes] -sha256 = "cb7654125e3dcb594269a6feb56ac693f1f13b7c654493a93eba72dc22d18034" - -[[packages]] -name = "pywin32-ctypes" -version = "0.2.3" -marker = "(\"test\" in dependency_groups and (platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'win32'))" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz" -upload-time = 2024-08-14 10:15:34+00:00 -size = 29471 - -[packages.sdist.hashes] -sha256 = "d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl" -upload-time = 2024-08-14 10:15:33+00:00 -size = 30756 - -[packages.wheels.hashes] -sha256 = "8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8" - -[[packages]] -name = "pyyaml" -version = "6.0.3" -marker = "\"release-hook-check\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-mise-editor\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-shell\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz" -upload-time = 2025-09-25 21:33:16+00:00 -size = 130960 - -[packages.sdist.hashes] -sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" - -[[packages]] -name = "readme-renderer" -version = "46.0" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/25/d7/9309494fad74ee831d4546f69325b5519f37c6dfb2d9ba495db8c6d4f4ca/readme_renderer-46.0.tar.gz" -upload-time = 2026-08-28 15:18:32+00:00 -size = 38382 - -[packages.sdist.hashes] -sha256 = "af3e964914f6310a33ff67b72a4bdd940bed8d7c3bdecd2d14f40edf284bfe90" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/4b/72/ac5ca81fe9121fcaa9d828d21017cba00a16a98e4ea5fb60c878f93dda4f/readme_renderer-46.0-py3-none-any.whl" -upload-time = 2026-08-28 15:18:31+00:00 -size = 14239 - -[packages.wheels.hashes] -sha256 = "d0dae1f74bb273b534770cb4cccb6bb78735540afdb03c2146f4e19dcd412560" - -[[packages]] -name = "release-creator" -version = "2.0.0" -marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/5d/40/55f7a22a3cda929f9efdf09dbb825e888487e658ce14892f2340218335c1/release_creator-2.0.0.tar.gz" -upload-time = 2026-10-25 07:08:31+00:00 -size = 209257 - -[packages.sdist.hashes] -sha256 = "f7ccec9787327a7e12f4a79c15dff470e894d3a88c9bb60eac9c6f1984ee63bf" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/88/f1/d7103a5c410019207015fc8229f976e8906da90409eddd5e00066bb360ca/release_creator-2.0.0-py3-none-any.whl" -upload-time = 2026-10-25 07:08:29+00:00 -size = 66805 - -[packages.wheels.hashes] -sha256 = "cf93c2395f4a9ba80bd503792c16189fc238c5d82bb90535250de2968d8d2e92" - -[[packages]] -name = "requests" -version = "2.34.2" -marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz" -upload-time = 2026-05-14 19:25:27+00:00 -size = 142856 - -[packages.sdist.hashes] -sha256 = "f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl" -upload-time = 2026-05-14 19:25:26+00:00 -size = 73075 - -[packages.wheels.hashes] -sha256 = "2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" - -[[packages]] -name = "requests-toolbelt" -version = "1.0.0" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz" -upload-time = 2023-05-01 04:11:33+00:00 -size = 206888 - -[packages.sdist.hashes] -sha256 = "7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl" -upload-time = 2023-05-01 04:11:28+00:00 -size = 54481 - -[packages.wheels.hashes] -sha256 = "cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" - -[[packages]] -name = "rfc3161-client" -version = "1.0.9" -marker = "\"release-attestations\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/56/53/e2f526fb6957023180c928448e2dd71b03b35038c315aed2d12d9a77c3af/rfc3161_client-1.0.9.tar.gz" -upload-time = 2026-09-14 19:32:29+00:00 -size = 112825 - -[packages.sdist.hashes] -sha256 = "66997415086d2c6e5d57a2d315157b32a9c3f4f1312877ca8eb55e2e0ddf8ce4" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/92/2c/b81b822bc067647aa291dc4ea71f052f994344934fc84d499759aec64cf8/rfc3161_client-1.0.9-cp39-abi3-macosx_10_12_x86_64.whl" -upload-time = 2026-09-14 19:32:08+00:00 -size = 2109721 - -[packages.wheels.hashes] -sha256 = "5e619d7e28ffa3a46685923e6d6ba4ce2e74cb09a41c77995a0702cc1a533bc4" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/88/cf/bcfb66eaeccdc076457451a6213360c840bcacd0e938e8507951be29373f/rfc3161_client-1.0.9-cp39-abi3-macosx_11_0_arm64.whl" -upload-time = 2026-09-14 19:32:10+00:00 -size = 2439706 - -[packages.wheels.hashes] -sha256 = "a7004edfbf1bb7ec7801978a39fa7a7fe385ca59673df90d7578079b6eba3b24" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/8e/f3/4b69d47fd7f79238bf8db20902a30d28c908efc0d906fb57abcc89ac75e3/rfc3161_client-1.0.9-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" -upload-time = 2026-09-14 19:32:12+00:00 -size = 2651455 - -[packages.wheels.hashes] -sha256 = "7b703b233dbee7228d117d5f1c118363c61d523fa6f018c9217c79e3c6684f86" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/33/49/154c3e80ac94e51b9d1097dab15b95f44aa593c395bd41775e4486a7d0a0/rfc3161_client-1.0.9-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl" -upload-time = 2026-09-14 19:32:13+00:00 -size = 2051342 - -[packages.wheels.hashes] -sha256 = "d3cbf1991b6f3457efcb25e1d31179cac556112e6d3e65ec9e0fd70c6e9af90d" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/c8/57/79bf6a67cd4ca729b0b74e6fa79ebef84e6bc8657b85772df01a7d3b7b89/rfc3161_client-1.0.9-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl" -upload-time = 2026-09-14 19:32:15+00:00 -size = 2418228 - -[packages.wheels.hashes] -sha256 = "614da494c6f8850c5d6d98c23bc291a4e4dbf2c3c9082d9907fa156f9e4a75c6" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/11/ae/19139fee184916065705d660bad203b676dc40b449afff36f79818c59c4f/rfc3161_client-1.0.9-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" -upload-time = 2026-09-14 19:32:17+00:00 -size = 2415566 - -[packages.wheels.hashes] -sha256 = "fac3f440a507555e684dc5daba75e33dc08f0f45fdefa48448f19001233a6b21" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/6e/54/43f89c8e004baade51943261b29f9dd866df3383f4554d5797a2568c2855/rfc3161_client-1.0.9-cp39-abi3-musllinux_1_2_aarch64.whl" -upload-time = 2026-09-14 19:32:19+00:00 -size = 2995975 - -[packages.wheels.hashes] -sha256 = "0ac247d5aa22f3514d1e284bdc547d2ad5f101e1e5d7f6db25db2ab3a0c59f06" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/2b/b2/938e4df767a2ea6fae2bc8e8e0df52e1d70a32a082a13bdfdd6fac59dc32/rfc3161_client-1.0.9-cp39-abi3-musllinux_1_2_armv7l.whl" -upload-time = 2026-09-14 19:32:20+00:00 -size = 2358728 - -[packages.wheels.hashes] -sha256 = "03be7ac7919c7f4c566076b07773b81b1f1b28d447fa91c6960fb5f82fac5bc1" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ff/54/f0193b27f1a635aa336d19b48c296f133719cafb87e950de6eae4638a046/rfc3161_client-1.0.9-cp39-abi3-musllinux_1_2_i686.whl" -upload-time = 2026-09-14 19:32:22+00:00 -size = 2565766 - -[packages.wheels.hashes] -sha256 = "8208999e94e1252e3f5f748f5efe59d3b9c308d04c7626be1becfbf3236c9de4" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/15/41/6c48b05fc41a424ab3b0cb0515e77046707b498837411633fe7ce77812ee/rfc3161_client-1.0.9-cp39-abi3-musllinux_1_2_x86_64.whl" -upload-time = 2026-09-14 19:32:24+00:00 -size = 2643597 - -[packages.wheels.hashes] -sha256 = "d4cf92f0e784ff02ff5ea1c574f8521f30953dc178092424126dbd8348034f93" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d2/18/fcb2f311b0b1754b0476165a4eae2929606007fc0ff0a575a65dbff2c0f6/rfc3161_client-1.0.9-cp39-abi3-win32.whl" -upload-time = 2026-09-14 19:32:25+00:00 -size = 1996018 - -[packages.wheels.hashes] -sha256 = "4d33f5aaec2974239e1c62a79a6fcf32506b09095482377a8b5be3a00fa67dfb" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d2/a1/b71d9f2071660a914e039061d3cdcad77b1cbccf7276bff073f95e8b4d1a/rfc3161_client-1.0.9-cp39-abi3-win_amd64.whl" -upload-time = 2026-09-14 19:32:27+00:00 -size = 2431351 - -[packages.wheels.hashes] -sha256 = "908f6a775da4bdce1d39f825a4431f389542696078ef3e4ed911a068bcaf1792" - -[[packages]] -name = "rfc3986" -version = "2.0.0" -marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz" -upload-time = 2022-01-10 00:52:30+00:00 -size = 49026 - -[packages.sdist.hashes] -sha256 = "97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl" -upload-time = 2022-01-10 00:52:29+00:00 -size = 31326 - -[packages.wheels.hashes] -sha256 = "50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd" - -[[packages]] -name = "rfc8785" -version = "0.1.4" -marker = "\"release-attestations\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/ef/2f/fa1d2e740c490191b572d33dbca5daa180cb423c24396b856f5886371d8b/rfc8785-0.1.4.tar.gz" -upload-time = 2024-09-27 16:33:31+00:00 -size = 14321 - -[packages.sdist.hashes] -sha256 = "e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/4d/78/119878110660b2ad709888c8a1614fce7e2fab39080ab960656dc8605bf6/rfc8785-0.1.4-py3-none-any.whl" -upload-time = 2024-09-27 16:33:29+00:00 -size = 9240 - -[packages.wheels.hashes] -sha256 = "520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48" - -[[packages]] -name = "rich" -version = "15.0.0" -marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz" -upload-time = 2026-04-12 08:24:00+00:00 -size = 230680 - -[packages.sdist.hashes] -sha256 = "edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl" -upload-time = 2026-04-12 08:24:02+00:00 -size = 310654 - -[packages.wheels.hashes] -sha256 = "33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" - -[[packages]] -name = "secretstorage" -version = "3.5.0" -marker = "(\"test\" in dependency_groups and (platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'))" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz" -upload-time = 2025-11-23 19:02:53+00:00 -size = 19884 - -[packages.sdist.hashes] -sha256 = "f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl" -upload-time = 2025-11-23 19:02:51+00:00 -size = 15554 - -[packages.wheels.hashes] -sha256 = "0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137" - -[[packages]] -name = "securesystemslib" -version = "1.5.1" -marker = "\"release-attestations\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/81/d4/f9323507144693d869f30f13694035438ab6642153b94e3057945cf0584f/securesystemslib-1.5.1.tar.gz" -upload-time = 2026-09-04 06:55:32+00:00 -size = 947087 - -[packages.sdist.hashes] -sha256 = "4b8d00abd93707ead10b69eb2b8582376a1364de3b0a71077de534c2ef4985e0" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/38/fc/c71335a5046d46bf3b761a8c7fa092b40a7721429a5613538f0a28ae80a6/securesystemslib-1.5.1-py3-none-any.whl" -upload-time = 2026-09-04 06:55:30+00:00 -size = 876390 - -[packages.wheels.hashes] -sha256 = "ada8bdf817da29ece4ba91654f6a162ce7cfbadbc3ae3f840f7313f9d22675de" - -[[packages]] -name = "setuptools" -version = "84.0.0" -marker = "\"build\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz" -upload-time = 2026-08-08 18:27:58+00:00 -size = 1168449 - -[packages.sdist.hashes] -sha256 = "f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl" -upload-time = 2026-08-08 18:27:56+00:00 -size = 818216 - -[packages.wheels.hashes] -sha256 = "51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670" - -[[packages]] -name = "sigstore" -version = "4.5.0" -marker = "\"release-attestations\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/18/e0/279419065e2d7102413605b3456122adbbccbc42e010b499c7b882fc01f8/sigstore-4.5.0.tar.gz" -upload-time = 2026-07-28 07:34:01+00:00 -size = 90969 - -[packages.sdist.hashes] -sha256 = "020d3e07f622b2916bf453e66ff6ff0711e1fdc5ab69e8bd8902f71d9fcb316f" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/bf/7f/51dc313c06dd3ec6c5b308e334492cfdea0b02c9184a0da7b2db8c2a30a1/sigstore-4.5.0-py3-none-any.whl" -upload-time = 2026-07-28 07:34:00+00:00 -size = 111724 - -[packages.wheels.hashes] -sha256 = "f045b207f2e12605cf775ec38e89c5eda625d71ffa7830477db65e47ec2bc8b2" - -[[packages]] -name = "sigstore-models" -version = "0.0.6" -marker = "\"release-attestations\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/c6/ed/5c0ff809f90b19f4e971e17c1ed11f4df60082c6010b32a82054087e91e0/sigstore_models-0.0.6.tar.gz" -upload-time = 2025-11-26 19:18:12+00:00 -size = 7037 - -[packages.sdist.hashes] -sha256 = "c766c09470c2a7e8a4a333c893f07e2001c56a3ff1757b1a246119f53169a849" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/12/dc/7a19864a7bc9f43121ab459e12768ab0080590e3e1b60a29b2f2eb01fb85/sigstore_models-0.0.6-py3-none-any.whl" -upload-time = 2025-11-26 19:18:11+00:00 -size = 13213 - -[packages.wheels.hashes] -sha256 = "5201a68f4d7d0f8bec1e2f4378eb646b084c52609a4e31db8c385095fff68b2e" - -[[packages]] -name = "sigstore-rekor-types" -version = "0.0.18" -marker = "\"release-attestations\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/b4/54/102e772445c5e849b826fbdcd44eb9ad7b3d10fda17b08964658ec7027dc/sigstore_rekor_types-0.0.18.tar.gz" -upload-time = 2024-11-22 13:59:54+00:00 -size = 15687 - -[packages.sdist.hashes] -sha256 = "19aef25433218ebf9975a1e8b523cc84aaf3cd395ad39a30523b083ea7917ec5" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/45/7c/f0b4e19fd424df4cc964f5d454e1d814fd2dc3b386342e6040441024318f/sigstore_rekor_types-0.0.18-py3-none-any.whl" -upload-time = 2024-11-22 13:59:52+00:00 -size = 20610 - -[packages.wheels.hashes] -sha256 = "b62bf38c5b1a62bc0d7fe0ee51a0709e49311d137c7880c329882a8f4b2d1d78" - -[[packages]] -name = "tomli" -version = "2.5.0" -marker = "\"lock-tools\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz" -upload-time = 2026-10-07 12:23:37+00:00 -size = 17662 - -[packages.sdist.hashes] -sha256 = "264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl" -upload-time = 2026-10-07 12:23:13+00:00 -size = 162775 - -[packages.wheels.hashes] -sha256 = "a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl" -upload-time = 2026-10-07 12:23:15+00:00 -size = 161406 - -[packages.wheels.hashes] -sha256 = "9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276" - -[[packages.wheels]] -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" -upload-time = 2026-10-07 12:23:16+00:00 -size = 273855 - -[packages.wheels.hashes] -sha256 = "752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52" - -[[packages.wheels]] -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" -upload-time = 2026-10-07 12:23:18+00:00 -size = 284910 - -[packages.wheels.hashes] -sha256 = "c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl" -upload-time = 2026-10-07 12:23:19+00:00 -size = 277723 - -[packages.wheels.hashes] -sha256 = "19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl" -upload-time = 2026-10-07 12:23:20+00:00 -size = 285115 - -[packages.wheels.hashes] -sha256 = "57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl" -upload-time = 2026-10-07 12:23:21+00:00 -size = 103475 - -[packages.wheels.hashes] -sha256 = "667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl" -upload-time = 2026-10-07 12:23:23+00:00 -size = 114589 - -[packages.wheels.hashes] -sha256 = "d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl" -upload-time = 2026-10-07 12:23:24+00:00 -size = 111493 - -[packages.wheels.hashes] -sha256 = "75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl" -upload-time = 2026-10-07 12:23:25+00:00 -size = 171380 - -[packages.wheels.hashes] -sha256 = "2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl" -upload-time = 2026-10-07 12:23:26+00:00 -size = 170553 - -[packages.wheels.hashes] -sha256 = "0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd" - -[[packages.wheels]] -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" -upload-time = 2026-10-07 12:23:28+00:00 -size = 294428 - -[packages.wheels.hashes] -sha256 = "49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01" - -[[packages.wheels]] -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" -upload-time = 2026-10-07 12:23:29+00:00 -size = 304909 - -[packages.wheels.hashes] -sha256 = "b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl" -upload-time = 2026-10-07 12:23:30+00:00 -size = 293220 - -[packages.wheels.hashes] -sha256 = "b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl" -upload-time = 2026-10-07 12:23:32+00:00 -size = 305705 - -[packages.wheels.hashes] -sha256 = "8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl" -upload-time = 2026-10-07 12:23:33+00:00 -size = 108432 - -[packages.wheels.hashes] -sha256 = "62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl" -upload-time = 2026-10-07 12:23:34+00:00 -size = 117281 - -[packages.wheels.hashes] -sha256 = "f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl" -upload-time = 2026-10-07 12:23:35+00:00 -size = 115069 - -[packages.wheels.hashes] -sha256 = "d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl" -upload-time = 2026-10-07 12:23:36+00:00 -size = 14765 - -[packages.wheels.hashes] -sha256 = "32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b" - -[[packages]] -name = "tomli-w" -version = "1.2.0" -marker = "\"lock-tools\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz" -upload-time = 2025-01-15 12:07:24+00:00 -size = 7184 - -[packages.sdist.hashes] -sha256 = "2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl" -upload-time = 2025-01-15 12:07:22+00:00 -size = 6675 - -[packages.wheels.hashes] -sha256 = "188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90" - -[[packages]] -name = "tomlkit" -version = "0.15.2" -marker = "\"lock-tools\" in dependency_groups or \"release-hook-check\" in dependency_groups or \"release-shell\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/08/72/fc01563df95c189a9f65850096bdc205bc2fe37da6c740cd64afaf18d07e/tomlkit-0.15.2.tar.gz" -upload-time = 2026-10-16 04:07:08+00:00 -size = 190522 - -[packages.sdist.hashes] -sha256 = "83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/da/46/7d84138a60a8bd796825b2d38ebe92d0d16501ca57d501b7c5d677401bbc/tomlkit-0.15.2-py3-none-any.whl" -upload-time = 2026-10-16 04:07:09+00:00 -size = 52225 - -[packages.wheels.hashes] -sha256 = "05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437" - -[[packages]] -name = "towncrier" -version = "26.9.0" -marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/92/d4/e85ce614bf45b1a7fe13b82cc687819c2eaac5a1dd94b84834322095fd98/towncrier-26.9.0.tar.gz" -upload-time = 2026-09-04 12:57:25+00:00 -size = 79338 - -[packages.sdist.hashes] -sha256 = "ace9031631c718cc0709107b8755e6e31350eb8acdc877af43df3177addb9a55" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/82/88/f9340b545dafa64de053798e760515a2736fbb60092cf9aa6b146ccf3ede/towncrier-26.9.0-py3-none-any.whl" -upload-time = 2026-09-04 12:57:23+00:00 -size = 67030 - -[packages.wheels.hashes] -sha256 = "ae4d223e6aadaff98d29b7f09e58d9c9ccf4cd3b455a2d2e0486d432cd8bf660" - -[[packages]] -name = "tox" -version = "4.67.0" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/4a/79/ce1ce8c98a1ff8ff318191ed1b4f113a3a8626fccfcedc687f7a485b5d7f/tox-4.67.0.tar.gz" -upload-time = 2026-10-17 19:15:47+00:00 -size = 346271 - -[packages.sdist.hashes] -sha256 = "d3e48816bebbd7bad0f6942a36fa7bcd14fe4ccbf4b3a5d54040ca0294dafd1f" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/18/f5/13f35b631325801434ba2609fe9225d1733006003519b093a233c55ebb73/tox-4.67.0-py3-none-any.whl" -upload-time = 2026-10-17 19:15:46+00:00 -size = 237718 - -[packages.wheels.hashes] -sha256 = "8fbe14416ba59543d3df3b9376537e5132fd2dd44176591285ecefb40b179533" - -[[packages]] -name = "tox-uv" -version = "1.37.0" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/2a/65/7cf72148bff33fd0a572e620c7b1573d6a5e4812e9207878d4e29e4a938a/tox_uv-1.37.0-py3-none-any.whl" -upload-time = 2026-10-11 00:33:49+00:00 -size = 6564 - -[packages.wheels.hashes] -sha256 = "f2e7de8f12d7ffd850d2a3dcebf278cf72a8952a9abe75d84cfb54a76ee17ae4" - -[[packages]] -name = "tox-uv-bare" -version = "1.37.0" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/31/27/203b4dbe73cc6dadc2480e319631310981540dabe95893de983106d7b103/tox_uv_bare-1.37.0.tar.gz" -upload-time = 2026-10-11 00:33:52+00:00 -size = 32961 - -[packages.sdist.hashes] -sha256 = "97418f2febec7da57d010ca6ffed8c7d56be9d425f381b74c7fe8d0213fccad9" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/52/4c/9596668bb448b868c684a3df9190103e9bc2f7de13e646628ac914f928be/tox_uv_bare-1.37.0-py3-none-any.whl" -upload-time = 2026-10-11 00:33:51+00:00 -size = 22322 - -[packages.wheels.hashes] -sha256 = "a596c0726690e2182cc85dfbc2f39e6fddacb8f7ea08bf8bace122a17397cd13" - -[[packages]] -name = "trove-classifiers" -version = "2026.9.21.13" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/bf/93/af436dfaa845cab5d96f0adbc1e4f3730532d37fa249e4eb796fb1d7fc82/trove_classifiers-2026.9.21.13.tar.gz" -upload-time = 2026-09-21 13:29:07+00:00 -size = 17085 - -[packages.sdist.hashes] -sha256 = "0a9ebc8d4e2f3e8a22848c5258033035bec17a3012ac3fea16dbaa764489eb71" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/30/81/0da8afb52a71d0a4f2bd3152357b1a441e393b286374802b9d3addab4ab5/trove_classifiers-2026.9.21.13-py3-none-any.whl" -upload-time = 2026-09-21 13:29:06+00:00 -size = 14232 - -[packages.wheels.hashes] -sha256 = "8b1ff4f9c191b1040b71c37f1e445ab99732911e3cd91de52838453a854d7a17" - -[[packages]] -name = "trustme" -version = "1.2.1" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/4b/c5/931476f4cf1cd9e736f32651005078061a50dc164a2569fb874e00eb2786/trustme-1.2.1.tar.gz" -upload-time = 2025-01-02 01:55:32+00:00 -size = 26844 - -[packages.sdist.hashes] -sha256 = "6528ba2bbc7f2db41f33825c8dd13e3e3eb9d334ba0f909713c8c3139f4ae47f" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b5/f3/c34dbabf6da5eda56fe923226769d40e11806952cd7f46655dd06e10f018/trustme-1.2.1-py3-none-any.whl" -upload-time = 2025-01-02 01:55:30+00:00 -size = 16530 - -[packages.wheels.hashes] -sha256 = "d768e5fc57c86dfc5ec9365102e9b092541cd6954b35d8c1eea01a84f35a762a" - -[[packages]] -name = "truststore" -version = "0.10.5" -marker = "\"lock-tools\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz" -upload-time = 2026-10-12 22:27:31+00:00 -size = 28091 - -[packages.sdist.hashes] -sha256 = "30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl" -upload-time = 2026-10-12 22:27:30+00:00 -size = 19017 - -[packages.wheels.hashes] -sha256 = "9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c" - -[[packages]] -name = "tuf" -version = "7.0.1" -marker = "\"release-attestations\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/9c/dd/52e7390cbac308e6b1cfe6be9bc3a96fe26326d5893cc297a52bb72792a9/tuf-7.0.1.tar.gz" -upload-time = 2026-09-02 06:19:52+00:00 -size = 274148 - -[packages.sdist.hashes] -sha256 = "5ada1db78da3518fa851e28588f4b2249b8662a28ae528f54afa2a1de541fb31" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a2/10/171e306e4a19ff980b9029ce5a523881e34bc6a2a52750fb1853f49f0fb9/tuf-7.0.1-py3-none-any.whl" -upload-time = 2026-09-02 06:19:50+00:00 -size = 56253 - -[packages.wheels.hashes] -sha256 = "d30434bda6e079ab303fb30d1b3006d939a10ca34783b1573d61cd9b802fa45c" - -[[packages]] -name = "twine" -version = "7.0.0" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz" -upload-time = 2026-07-27 15:59:00+00:00 -size = 215032 - -[packages.sdist.hashes] -sha256 = "85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl" -upload-time = 2026-07-27 15:58:59+00:00 -size = 43204 - -[packages.wheels.hashes] -sha256 = "b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7" - -[[packages]] -name = "ty" -version = "0.0.89" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/67/ea/dfb9bdbc168c50a568809466596751afa0c7da60da03b8c12906d1decde4/ty-0.0.89.tar.gz" -upload-time = 2026-10-16 10:35:05+00:00 -size = 7978241 - -[packages.sdist.hashes] -sha256 = "bc1027b1ea95cca25c2a3a0b41433c809cf4b86a384d2b2434eba4bcd34548d4" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/c5/84/e199d9a3dc3c94c804ddd9e94b63dbe543ae7f39329574963b6a6a95872f/ty-0.0.89-py3-none-linux_armv6l.whl" -upload-time = 2026-10-16 10:34:24+00:00 -size = 14254879 - -[packages.wheels.hashes] -sha256 = "739b087e8873e177ffda99ff8a3600a4a7704d06523a8e88c4493021eee40b8a" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e5/d5/5afe09bf5c83c4e3a9e5913386a460e240a7029f2ae5fc96df5b43563a91/ty-0.0.89-py3-none-macosx_10_12_x86_64.whl" -upload-time = 2026-10-16 10:34:27+00:00 -size = 13790417 - -[packages.wheels.hashes] -sha256 = "47ebc98480c8a301955e18a8bdd67ef07090071ad47349b3254f325d3d5ab4e7" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/7e/f9/88648f0ba9400dfd642c1e8dc0aeecb87945b121f3ff51f3902883c016d6/ty-0.0.89-py3-none-macosx_11_0_arm64.whl" -upload-time = 2026-10-16 10:34:29+00:00 -size = 12856900 - -[packages.wheels.hashes] -sha256 = "b0631291af5c850a15007c0ffb906d813966466e4ec12173c44977c0c5a2d51d" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/6a/89/baae2b407101c4cb02f19216123900c147af8fd87cdb1ed7eba139472734/ty-0.0.89-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" -upload-time = 2026-10-16 10:34:32+00:00 -size = 13653330 - -[packages.wheels.hashes] -sha256 = "3d768300ae16274ce656006e9199869ad68684b74400d963e1c82deddf3a22ad" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ed/1d/293516c2e10a77134cfc40d22c3fc08edd8c5eadae9c75531ec8a9263348/ty-0.0.89-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl" -upload-time = 2026-10-16 10:34:34+00:00 -size = 13986215 - -[packages.wheels.hashes] -sha256 = "3dd4c57ba58bab8f6c816b2c254db52b58834b5dbf8268287382f7a2f9735c8f" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/2f/c3/da911c8fc22d2664692432570619576b25c7cd0a586401cab08987763094/ty-0.0.89-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl" -upload-time = 2026-10-16 10:34:36+00:00 -size = 14905008 - -[packages.wheels.hashes] -sha256 = "1af774c24117d366b69142d4f35edee518617f8b926d13560d0334cbe5aa3f85" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/5c/c3/aed8d920b9bbf7b582e9fc0305acb2f2c748ede788e7dba9d6952e939c78/ty-0.0.89-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" -upload-time = 2026-10-16 10:34:39+00:00 -size = 15278217 - -[packages.wheels.hashes] -sha256 = "a8147811b9b3bc2dc9338cd01c7893ac931fc3fae7aace169047ecbd627e6628" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/96/92/c6b4320f08fca44ffe279e5ddeb3eae12869c6341f596931accdefea1182/ty-0.0.89-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl" -upload-time = 2026-10-16 10:34:41+00:00 -size = 15206289 - -[packages.wheels.hashes] -sha256 = "6d5f178c91d50fb62461b5e579028b7d2e92872e1d8f02e96ace26464c2277a6" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/3e/61/da22c714fd4f4dff642d60fb0d8bf8da1985d126f21cc89237f8bf707c92/ty-0.0.89-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" -upload-time = 2026-10-16 10:34:44+00:00 -size = 14510395 - -[packages.wheels.hashes] -sha256 = "95a410f10b26cc3861757b626727db9d979deba7d2a3ab59f964fac8f0514fdc" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f0/32/5f9ce2377a77ad9ff36d8996edf86b1f069c1e8716e37c7d6d668402b110/ty-0.0.89-py3-none-manylinux_2_31_riscv64.whl" -upload-time = 2026-10-16 10:34:46+00:00 -size = 14767366 - -[packages.wheels.hashes] -sha256 = "a70ab1a402746d7837407dc169e461fdf90945bc1eef0d3c0f1be543490060bf" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/63/72/2b28c71d6ebd97b5bb1378f1806598c5dd90278f7b99a431dd3879dddcfc/ty-0.0.89-py3-none-musllinux_1_2_aarch64.whl" -upload-time = 2026-10-16 10:34:48+00:00 -size = 13670284 - -[packages.wheels.hashes] -sha256 = "b55c28a49bd2676eac9b3540a1dcc4cc053e4671c65dcec9fdad3dfe55fb148c" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/34/cc/68b8ebf98c9bc582fddc3d2b93f6e0f53719d1ddee1c5037dfa2e1cb8467/ty-0.0.89-py3-none-musllinux_1_2_armv7l.whl" -upload-time = 2026-10-16 10:34:50+00:00 -size = 13997891 - -[packages.wheels.hashes] -sha256 = "4c9f22edeb8176f8ab189337e8cbd8f92b66744534a2886df9e252b52ade991c" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/cf/4d/a1d61e06092fd5726f48a8711bd32eac6af132308eb222a0027caba4f42d/ty-0.0.89-py3-none-musllinux_1_2_i686.whl" -upload-time = 2026-10-16 10:34:53+00:00 -size = 14326247 - -[packages.wheels.hashes] -sha256 = "0c186185cad57a1469b851096b51345de7b625e353c1f4acd338402daed22fa7" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f8/d2/466d3762e330504a2c114dbbd0f0383229f9652dccd56277dde008bfe0da/ty-0.0.89-py3-none-musllinux_1_2_x86_64.whl" -upload-time = 2026-10-16 10:34:55+00:00 -size = 14661527 - -[packages.wheels.hashes] -sha256 = "5e38302ac218d2404758241093b5542ed0345d4f569096589f1810034244e998" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b1/d5/dfd7ed5e4af472b20a11ef651da4bca55f9dc8be485cd152337f94caddde/ty-0.0.89-py3-none-win32.whl" -upload-time = 2026-10-16 10:34:57+00:00 -size = 13224078 - -[packages.wheels.hashes] -sha256 = "0146637ed2321717280bfe4e4b20a18813b741da280e8039d7df2bf22b0251f1" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b4/14/1ee663c422e3a2d910c5cc27e7d84bd7709a3df0aca28a713843c6c3fa5d/ty-0.0.89-py3-none-win_amd64.whl" -upload-time = 2026-10-16 10:35:00+00:00 -size = 13727433 - -[packages.wheels.hashes] -sha256 = "9fc56322a1aff29a19088d2025f3c42a6d25a7035668048bb908c9f8b66f480d" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/09/93/af8f7250920823ca6d582bcf0abf803fb92c8b99415ba7f40f2304102c6f/ty-0.0.89-py3-none-win_arm64.whl" -upload-time = 2026-10-16 10:35:02+00:00 -size = 13287101 - -[packages.wheels.hashes] -sha256 = "1d4967c8f7bfd67c9708115b41d7049197c5d20c76fb2e136fe78b0437ada05b" - -[[packages]] -name = "typing-extensions" -version = "4.16.0" -marker = "\"lock-tools\" in dependency_groups or \"release-attestations\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz" -upload-time = 2026-07-02 08:40:05+00:00 -size = 113555 - -[packages.sdist.hashes] -sha256 = "dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl" -upload-time = 2026-07-02 08:40:04+00:00 -size = 45571 - -[packages.wheels.hashes] -sha256 = "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" - -[[packages]] -name = "typing-inspection" -version = "0.4.4" -marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz" -upload-time = 2026-08-12 12:37:25+00:00 -size = 76928 - -[packages.sdist.hashes] -sha256 = "547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl" -upload-time = 2026-08-12 12:37:24+00:00 -size = 14750 - -[packages.wheels.hashes] -sha256 = "65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147" - -[[packages]] -name = "urllib3" -version = "2.8.0" -marker = "\"lock-tools\" in dependency_groups or \"release-attestations\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz" -upload-time = 2026-09-15 19:29:36+00:00 -size = 458972 - -[packages.sdist.hashes] -sha256 = "63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl" -upload-time = 2026-09-15 19:29:34+00:00 -size = 135717 - -[packages.wheels.hashes] -sha256 = "0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3" - -[[packages]] -name = "uv" -version = "0.13.5" -marker = "\"release-resolver\" in dependency_groups or \"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/de/f0/7565e80415f841c3500ed69d86516c7676e3b24470a218ab61b1f43071a2/uv-0.13.5.tar.gz" -upload-time = 2026-10-22 01:32:33+00:00 -size = 7513309 - -[packages.sdist.hashes] -sha256 = "979409b8c9a78df140ffb19c53f511b7a73526da8d1d5e36558833055e371c6c" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/24/ff/e30478710f53cf388c892e27e90662aa2acac7a7998bdb18023c595213c1/uv-0.13.5-py3-none-linux_armv6l.whl" -upload-time = 2026-10-22 01:31:37+00:00 -size = 18325046 - -[packages.wheels.hashes] -sha256 = "8d092d07d2389909027c2ed4965ade1bd46bbcecceb26686ff40927684f8c457" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e3/2a/dcae794203261e49564bb9ff7c4b64a093f69ac73476cc8ce54c1deed3a1/uv-0.13.5-py3-none-macosx_10_12_x86_64.whl" -upload-time = 2026-10-22 01:31:40+00:00 -size = 17335724 - -[packages.wheels.hashes] -sha256 = "337a14829bb499e114989df4f908f134ff37f1fb6c33497541cfb05b28080e39" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d2/26/9ac45211123244ea68be852775093af487a64debc13621a2de239f8ebe73/uv-0.13.5-py3-none-macosx_11_0_arm64.whl" -upload-time = 2026-10-22 01:31:43+00:00 -size = 14470685 - -[packages.wheels.hashes] -sha256 = "a42c85fa8156e1e5e2cecdbc77bb818ea1dcf5f78b8e2a8f84c7fe4f16f0702e" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e8/2e/5bf9acf9d11fb790420e258a875e13bba36bc890858350642aca185efe0e/uv-0.13.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl" -upload-time = 2026-10-22 01:31:46+00:00 -size = 17784583 - -[packages.wheels.hashes] -sha256 = "2386c2dc9b59fc1d39b1c500e4319d71d3268232eb3b38e29cd6775d6269e8d4" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f3/70/b02fdcbae77346e3ec1310e7de9dde541252e709f6641db3fbd32cd4da79/uv-0.13.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl" -upload-time = 2026-10-22 01:31:50+00:00 -size = 17866408 - -[packages.wheels.hashes] -sha256 = "4f62680fe58c5a83fd539e38f1956c2b577b1f5939c9020aa4c7f0addda2f4ad" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/fd/b9/6663095851e0943e05e47318d7600e567bc172a1e7a26b21b8e6608e8491/uv-0.13.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl" -upload-time = 2026-10-22 01:31:53+00:00 -size = 17753574 - -[packages.wheels.hashes] -sha256 = "0e03f5717bdaaf5ab4e2df361d2395e36b515946f56ef4bed88801aeb9959fb0" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e5/84/1bb89a94486b813bbeaa1b8b491e69a25fa864626711944127b186f903de/uv-0.13.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl" -upload-time = 2026-10-22 01:31:56+00:00 -size = 18502087 - -[packages.wheels.hashes] -sha256 = "f867c5ec24c563afaae730a13e46cdf74a1a0b64c134286ff99762a96e2abf40" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a4/a1/b0b0c7247f3bb0f307eae8a28679ecd0d4b0e2757fcf6c87dea6f679a099/uv-0.13.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" -upload-time = 2026-10-22 01:31:59+00:00 -size = 19927049 - -[packages.wheels.hashes] -sha256 = "d3fd5db55c20feb672ea457361aba29ab68b9ae69a14d98ad6c138b5f86dcca4" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b4/44/6bf11de78ce18869849a14178534b900dbe4e310270b2f60df6503c03137/uv-0.13.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl" -upload-time = 2026-10-22 01:32:03+00:00 -size = 19662743 - -[packages.wheels.hashes] -sha256 = "e01481ae5212e45ee7c85fad5fc67f06b0ee0329a7c3c289dc529da884b03881" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/3a/ad/3c2a81d66d1839063662e5b6c0002ec31f8ee987dbc5295747627ca6160a/uv-0.13.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" -upload-time = 2026-10-22 01:32:06+00:00 -size = 17211825 - -[packages.wheels.hashes] -sha256 = "fa8d26e08e34c29c0c4e820cf3181757086d547240b4f4d31ff1d8bd810ca946" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/4c/92/d83d019c63c1ccc6ff17df2bfe76270412f9766e7e71aa4d4ccbf018683f/uv-0.13.5-py3-none-manylinux_2_28_aarch64.whl" -upload-time = 2026-10-22 01:32:10+00:00 -size = 16654136 - -[packages.wheels.hashes] -sha256 = "91e58e304b63b6c91bac5561459ab0c3ea24f17bc01ea9359d69fed72142978f" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b8/5a/d42f9d93b322ad36647eb3bf516cc528847c240f630f6df213b6221c5262/uv-0.13.5-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl" -upload-time = 2026-10-22 01:32:13+00:00 -size = 17921105 - -[packages.wheels.hashes] -sha256 = "309a74d3c488bfaa3ba7bff759da73193d8781729c0a01274f4940fd32bb5712" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/97/79/c5c2c39c78cf7a1dd6e041b3f7aa2cc4258d351d42dea06f6fee0bde5daa/uv-0.13.5-py3-none-manylinux_2_31_riscv64.whl" -upload-time = 2026-10-22 01:32:16+00:00 -size = 18103266 - -[packages.wheels.hashes] -sha256 = "469cca66de1b17c75b9b9de806a64cffb57cba5e42b1e30d0bba6fa72a146f58" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/01/2f/e5c70a05013077e5ecadb804418ddbab33f7279f96d052f79f565369b2a7/uv-0.13.5-py3-none-musllinux_1_1_i686.whl" -upload-time = 2026-10-22 01:32:19+00:00 -size = 17842061 - -[packages.wheels.hashes] -sha256 = "2386e8dbfe110ff2f8ec8bd344dd18bf39eb9eb54e1edeeb87c3b85c34229cd3" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/4f/b0/7dba2cb636d7f58e0d74e948ad1afc6b7923e84e82de025e9951a68055db/uv-0.13.5-py3-none-musllinux_1_1_x86_64.whl" -upload-time = 2026-10-22 01:32:22+00:00 -size = 18963145 - -[packages.wheels.hashes] -sha256 = "d162669e3a7811c584b03690eaa11e796ad1d309aa8229d5dfe3cbcd68a90400" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/00/7c/e38b312abaae1bd59ff5886126f48e9e223fbb9a8edf9e236ede976f67cb/uv-0.13.5-py3-none-win32.whl" -upload-time = 2026-10-22 01:32:25+00:00 -size = 16240919 - -[packages.wheels.hashes] -sha256 = "f4e55a6e2a1089fe74ed50fbdf2df0f6f336ee148ad232b05932db5deee8007e" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/32/ea/771744a784b16aa21d195cb5d5d5a2878d72fa00fc26f11ea3f9646994fe/uv-0.13.5-py3-none-win_amd64.whl" -upload-time = 2026-10-22 01:32:28+00:00 -size = 15358650 - -[packages.wheels.hashes] -sha256 = "1fb29fc46ba12033e2ef95a95ac79d649e71bbb1358419bbbcbd19d86026ef2f" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/52/67/1f31243f75ece410f2db1afdfc9a9f9722fdee2385e4a347c3ca1dee6d13/uv-0.13.5-py3-none-win_arm64.whl" -upload-time = 2026-10-22 01:32:31+00:00 -size = 15533668 - -[packages.wheels.hashes] -sha256 = "0548460d46c8667488b1f089e936f29f0edfb1cc753f9152ffbd8bc84a46aa20" - -[[packages]] -name = "virtualenv" -version = "21.14.11" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/7f/be/4d1d59d309db76b5808fcfd78bd5996f4e7241366691c38fc331a3136398/virtualenv-21.14.11.tar.gz" -upload-time = 2026-10-18 07:06:34+00:00 -size = 5503091 - -[packages.sdist.hashes] -sha256 = "593052d8477ea8c55a842387ce111bd08c72618f0c1133c302cafe335e570a57" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ce/6b/d85da8982b41190eafcb9df91dff9d83fc6989e98af4b5be4c8f85510c95/virtualenv-21.14.11-py3-none-any.whl" -upload-time = 2026-10-18 07:06:32+00:00 -size = 5495268 - -[packages.wheels.hashes] -sha256 = "b319f8590a84e5de5f8f2c803ecd2237e9bc0ffa7c1e875e3169c107cecb7454" - -[[packages]] -name = "wcwidth" -version = "0.9.2" -marker = "\"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/f0/b4/7830542634bb2d3e62aa3b586a72d5b3b6c91c3168929e7000ef3fed041d/wcwidth-0.9.2.tar.gz" -upload-time = 2026-10-05 00:24:05+00:00 -size = 955039 - -[packages.sdist.hashes] -sha256 = "ae0ef90b90f6af38b54f1fe6d58662ec33b3cb4b8391958a62416d654231727b" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/59/1e/4532a81fb9dfbf4114a816775e0a36c3a64ee1d1f4bba2094e2da50be5dc/wcwidth-0.9.2-cp310-abi3-macosx_10_9_x86_64.whl" -upload-time = 2026-10-05 00:23:22+00:00 -size = 605343 - -[packages.wheels.hashes] -sha256 = "7ef5a940bd5e30bac6e721f1a48fce0cd7bb3ece19e9c5d139e72c76c35cfd07" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a0/07/cb6940e81134b7ed25fa312ee9ab536a63db0793b149f88a90e603ceace9/wcwidth-0.9.2-cp310-abi3-macosx_11_0_arm64.whl" -upload-time = 2026-10-05 00:23:27+00:00 -size = 609960 - -[packages.wheels.hashes] -sha256 = "ae0800c5339423cc53d33a266ad264b42ba8aaa16d4464f6e6b1bee607f50b17" - -[[packages.wheels]] -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" -upload-time = 2026-10-05 00:23:38+00:00 -size = 762566 - -[packages.wheels.hashes] -sha256 = "9e542f1f8475b78452a295495d7a5bc3ead565112e9446a64dc93462a41c2a79" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/bc/f0/b8ef7758003d66b60f093695831a86dcc726aac01ee6446ffcbda27b61e3/wcwidth-0.9.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" -upload-time = 2026-10-05 00:23:32+00:00 -size = 771632 - -[packages.wheels.hashes] -sha256 = "674b518af28d38ee645ff97b74f5760abee5fad4bac74413bfc4b881ef2ce724" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/db/6c/f940133c71427c208575910e981942bd78c98b1f7cd0d1425ca4b7457c04/wcwidth-0.9.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" -upload-time = 2026-10-05 00:23:40+00:00 -size = 769763 - -[packages.wheels.hashes] -sha256 = "751bef0ab404b6a1dc028b56b4b85d46486be1c55833f80da533e42dc691f389" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/92/8f/285f862826f721964ec7c42f81dc53d23afbd723a0f4cd989651f8218e25/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_aarch64.whl" -upload-time = 2026-10-05 00:23:33+00:00 -size = 782134 - -[packages.wheels.hashes] -sha256 = "c3d80f39ba4653a595edae9aa46a509d14883790a8fc23c5db221ceb207f64b7" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/c2/2d/64aa54882a5d556d3654c1f926d9118b797461033e23a158409941a37c8f/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_i686.whl" -upload-time = 2026-10-05 00:23:41+00:00 -size = 782851 - -[packages.wheels.hashes] -sha256 = "0a47e03d8293590ecce66c45dc20ff7b4b885e3c78093722239585eca0d77ab2" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/59/39/52389f6de7fe2e9c14ceb8253dd99034bd86e1c87847ea3c100a97dded9a/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_x86_64.whl" -upload-time = 2026-10-05 00:23:43+00:00 -size = 784621 - -[packages.wheels.hashes] -sha256 = "67d901a4ad99249eb775b4ee4769ca97fa405d35a75f46e83166910a47003f04" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b3/8b/20225500a076ace27bbcc8a6fd7c55125133c57a618816c7b7b8b73070b1/wcwidth-0.9.2-cp310-abi3-win32.whl" -upload-time = 2026-10-05 00:23:55+00:00 -size = 593123 - -[packages.wheels.hashes] -sha256 = "ee1fd0db9d9fd711a70f3e7765e0e04c05d26982fa05361456163062549d7da4" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/5a/d6/b0690f55ea0483530a18bac917fbadbf54f35122510446fc370f5f1c2453/wcwidth-0.9.2-cp310-abi3-win_amd64.whl" -upload-time = 2026-10-05 00:23:57+00:00 -size = 597827 - -[packages.wheels.hashes] -sha256 = "2a9746de704242bd4fdaabb31dd46b82f694a56a8d21081ad89b679a89da9fec" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e5/11/6ecf4e9e268ab1a4ec617ffcccc2ee4a71301625f5490912dbaba462fa9c/wcwidth-0.9.2-cp310-abi3-win_arm64.whl" -upload-time = 2026-10-05 00:23:51+00:00 -size = 598415 - -[packages.wheels.hashes] -sha256 = "b9c6ab615e03723b7f8760ea2f27758d656e7e13b51515c9dca5c3e8b04612fa" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/82/8a/a28d61d910005ac93dfe48be3a0ebaa49352d88cebd25323e69e6ff2f4a8/wcwidth-0.9.2-cp315-cp315t-macosx_10_15_x86_64.whl" -upload-time = 2026-10-05 00:23:25+00:00 -size = 606575 - -[packages.wheels.hashes] -sha256 = "42dbcb76ce8af39e2c9db410ac3f9bdf4e47eb41d6f44525952f172d3d98f724" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/01/c2/a3c66bd32766c8f4d6dc47d572532ba014fe5be30489f2576aff7cada363/wcwidth-0.9.2-cp315-cp315t-macosx_11_0_arm64.whl" -upload-time = 2026-10-05 00:23:30+00:00 -size = 611045 - -[packages.wheels.hashes] -sha256 = "138e1f8898e431b2f2d7881f8ca8d75591c1d3c21aa53f54e989bd6b39811da2" - -[[packages.wheels]] -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" -upload-time = 2026-10-05 00:23:47+00:00 -size = 778143 - -[packages.wheels.hashes] -sha256 = "5175609bf8cc7398a5f48aa35207bd64ebf9f45e4c70df65f7fdc7a988041a3c" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/2f/53/525da13e8f9ff7b5b4e74ec6f8d68bdee63905796972e086c6b1b96670d2/wcwidth-0.9.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" -upload-time = 2026-10-05 00:23:36+00:00 -size = 786630 - -[packages.wheels.hashes] -sha256 = "e5f669ae8c3d969c72032f9cdee019674b666e522d45e1e2099a2e9dda4a341d" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ef/9f/d6a0c6df354b9d93466548a65cbf4ffcb48c719bbd307504cf3e76740837/wcwidth-0.9.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" -upload-time = 2026-10-05 00:23:49+00:00 -size = 784839 - -[packages.wheels.hashes] -sha256 = "196b47cf32f9df27ccda6dc513237f3c2429c4c659db428d60a5bc443d10f270" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/bf/d7/3021feed1ed7926021ec134943ad3b24a2f7ea742cc9976461171482ed77/wcwidth-0.9.2-cp315-cp315t-win32.whl" -upload-time = 2026-10-05 00:24:02+00:00 -size = 600845 - -[packages.wheels.hashes] -sha256 = "0cd4f7f2e53905dcb110d213a4c8529b6733fa3d232d8c717f946cc69a10349b" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/63/80/6a03356d8ee38261e3a78cf89ee03d8e7f12c572d969237be00869e2dc73/wcwidth-0.9.2-cp315-cp315t-win_amd64.whl" -upload-time = 2026-10-05 00:24:04+00:00 -size = 605441 - -[packages.wheels.hashes] -sha256 = "33df042f96c61ed3cd5fb3742fba427553a635bc578799857a48aa79f774a0b9" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/0c/48/1a308a86a833fd12ff7a08d0d2491ff4a72c8a92d12f5ead8317630f771e/wcwidth-0.9.2-cp315-cp315t-win_arm64.whl" -upload-time = 2026-10-05 00:23:54+00:00 -size = 605885 - -[packages.wheels.hashes] -sha256 = "48719a9bc76c2f84238693fe5013571fa5beffa3621cf228f1f3a9e30dae84b8" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/9c/b4/0bfa065af506540d9d558e3e5548cff00bc1f9b24e6e2a8512498e8628de/wcwidth-0.9.2-py3-none-any.whl" -upload-time = 2026-10-05 00:23:21+00:00 -size = 301667 - -[packages.wheels.hashes] -sha256 = "89ca642c5bf0101157a09366be69fad0379db1f700ae39a920e103234573670e" - -[[packages]] -name = "werkzeug" -version = "3.1.9" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz" -upload-time = 2026-09-27 18:33:41+00:00 -size = 940188 - -[packages.sdist.hashes] -sha256 = "55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl" -upload-time = 2026-09-27 18:33:39+00:00 -size = 228700 - -[packages.wheels.hashes] -sha256 = "6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab" - -[[packages]] -name = "wheel-filename" -version = "1.4.2" -marker = "\"test\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/38/be/726dab762b770d0417e505c58e26d661aac1ec0c831e483cda4817ca2417/wheel_filename-1.4.2.tar.gz" -upload-time = 2024-12-01 13:03:16+00:00 -size = 7911 - -[packages.sdist.hashes] -sha256 = "87891c465dcbb40b40394a906f01a93214bdd51aa5d25e3a9a59cae62bc298fd" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b4/0f/6e97a3bc38cdde32e3ec49f8c0903fe3559ec9ec9db181782f0bb4417717/wheel_filename-1.4.2-py3-none-any.whl" -upload-time = 2024-12-01 13:03:00+00:00 -size = 6195 - -[packages.wheels.hashes] -sha256 = "3fa599046443d4ca830d06e3d180cd0a675d5871af0a68daa5623318bb4d17e3" - -[[packages]] -name = "xmltodict" -version = "1.0.4" -marker = "\"release-hook-check\" in dependency_groups or \"release-shell\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz" -upload-time = 2026-02-22 02:21:22+00:00 -size = 26124 - -[packages.sdist.hashes] -sha256 = "6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl" -upload-time = 2026-02-22 02:21:21+00:00 -size = 13580 - -[packages.wheels.hashes] -sha256 = "a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a" - -[[packages]] -name = "yq" -version = "4.4.5" -marker = "\"release-hook-check\" in dependency_groups or \"release-shell\" in dependency_groups" -index = "https://pypi.org/simple/" - -[packages.sdist] -url = "https://files.pythonhosted.org/packages/fd/be/ae37199d28a0a99eb79084f91da94f6e7e58550552361a2397c5032d5d37/yq-4.4.5.tar.gz" -upload-time = 2026-10-11 02:20:56+00:00 -size = 54535 - -[packages.sdist.hashes] -sha256 = "e17b21a927d04ae33f61a7284351b69b0a539bfb73b852a04a907b79bcd15de2" - -[[packages.wheels]] -url = "https://files.pythonhosted.org/packages/fd/38/cca2b04f284c788ded19a9401e3049fd1f82d0467c1d6bd361915a77c063/yq-4.4.5-py3-none-any.whl" -upload-time = 2026-10-11 02:20:55+00:00 -size = 30255 - -[packages.wheels.hashes] -sha256 = "4e8b4e54979b26554c72a7e669009017a7fdd3fb00bbae0b26f82a86636c289f" - -[tool.release-creator] -created-at = 2026-10-25 07:10:27.737557+00:00 -uv-version = "0.13.5" diff --git a/pyproject.toml b/pyproject.toml index 9fad360..aa5baba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,19 +8,6 @@ license = "MIT" license-files = [ "LICENSE" ] [dependency-groups] -build = [ "Cython>=3.0", "setuptools>=80" ] -lock-tools = [ "nab==0.0.22", "tomlkit>=0.13" ] -lock-reader = [ "certifi>=2026.7.22", "packaging>=26.3" ] -release-notes = [ "release-creator>=2.0.0" ] -release-mise-editor = [ "pyyaml>=6" ] -release-news = [ "release-creator>=2.0.0", "towncrier>=26.9" ] -release-prepare = [ "release-creator>=2.0.0", "towncrier>=26.9", "prek>=0.2" ] -release-validate = [ "release-creator>=2.0.0", "prek>=0.2" ] -release-check = [ "prek>=0.2" ] -release-attestations = [ "certifi>=2026.7.22", "pypi-attestations>=0.0.30" ] -release-shell = [ "prek>=0.2", "yq>=4.4.5" ] -release-hooks = [ "release-creator>=2.0.0", "prek>=0.2" ] -release-hook-check = [ "prek>=0.2", "yq>=4.4.5" ] test = [ "certifi", "check-wheel-contents>=0.6.3", @@ -39,16 +26,6 @@ test = [ "ty", ] -[tool.release-creator.locks.exclude-newer-package] -nab = "2026-10-18T05:45:09Z" -nab-index = "2026-10-18T05:45:09Z" -nab-markersets = "2026-10-18T05:45:09Z" -nab-project = "2026-10-18T05:45:09Z" -nab-provider = "2026-10-18T05:45:09Z" -nab-resolver = "2026-10-18T05:45:09Z" -release-creator = "2026-10-25T07:08:32Z" -uv = "2026-10-22T01:32:34Z" - [tool.ruff] line-length = 120 diff --git a/reconcile-pypi-artifacts/action.yml b/reconcile-pypi-artifacts/action.yml index 48ef3a2..eadac61 100644 --- a/reconcile-pypi-artifacts/action.yml +++ b/reconcile-pypi-artifacts/action.yml @@ -10,15 +10,22 @@ inputs: dist: description: Directory containing the complete expected distribution set required: true + exclude-newer: + description: Upload cutoff for the tools this Action installs from PyPI + default: 7 days + exclude-newer-package: + description: Newline-separated name=date cutoffs that override exclude-newer for single packages + default: "" runs: using: composite steps: - - name: Install locked provenance verifier - uses: $/install-locked-tools - with: - groups: release-attestations - venv: ${{ runner.temp }}/pypi-attestations - lock-source: shared + - name: Install provenance verifier + shell: bash + env: + EXCLUDE_NEWER: ${{ inputs.exclude-newer }} + EXCLUDE_NEWER_PACKAGE: ${{ inputs.exclude-newer-package }} + run: >- + "$GITHUB_ACTION_PATH/../tools/install_live_tools.sh" "$RUNNER_TEMP/pypi-attestations" 'certifi>=2026.7.22' 'pypi-attestations>=0.0.30' - name: Compare published files shell: bash env: diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index 38b234c..710798e 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -22,6 +22,12 @@ inputs: mode: description: Refresh the lock or check that the committed lock is current default: refresh + exclude-newer: + description: Upload cutoff for the tools this Action installs from PyPI + default: 7 days + exclude-newer-package: + description: Newline-separated name=date cutoffs that override exclude-newer for single packages + default: "" outputs: changed: description: Whether the locks differ from the checkout @@ -46,11 +52,12 @@ runs: echo '::error::secondary matrix inputs require additional-lock' >&2 exit 1 fi - - uses: $/install-locked-tools - with: - groups: lock-tools - lock-source: shared - venv: ${{ runner.temp }}/lock-tools + - name: Install Nab + shell: bash + env: + EXCLUDE_NEWER: ${{ inputs.exclude-newer }} + EXCLUDE_NEWER_PACKAGE: ${{ inputs.exclude-newer-package }} + run: '"$GITHUB_ACTION_PATH/../tools/install_live_tools.sh" "$RUNNER_TEMP/lock-tools" nab==0.0.22' - name: Resolve all project groups and extras shell: bash env: diff --git a/tests/conftest.py b/tests/conftest.py index 4d0ef7c..b8ac1e9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -143,25 +143,10 @@ def pypi_metadata_environment(pypi_metadata: Path, monkeypatch: pytest.MonkeyPat @pytest.fixture(scope="session") def nab_executable(tmp_path_factory: pytest.TempPathFactory) -> Path: - root = Path(__file__).resolve().parents[1] environment = tmp_path_factory.mktemp("nab-tools") - subprocess.run(["uv", "venv", "--python", "3.15", str(environment)], check=True, capture_output=True) subprocess.run( - [ - "uv", - "pip", - "sync", - "--preview-features", - "pylock", - "--python", - str(environment / "bin/python"), - "--group", - "lock-tools", - "--only-binary", - ":all:", - "--require-hashes", - str(root / "pylock.toml"), - ], + [str(Path(__file__).resolve().parents[1] / "tools/install_live_tools.sh"), str(environment), "nab==0.0.22"], + env=os.environ | {"EXCLUDE_NEWER": "7 days"}, check=True, capture_output=True, ) @@ -262,9 +247,9 @@ def source_project(repository: Path) -> Path: def build_repository(source_project: Path, nab_executable: Path, monkeypatch: pytest.MonkeyPatch) -> Path: monkeypatch.delenv("SSL_CERT_FILE", raising=False) project = source_project / "pyproject.toml" - release_requirement = tomllib.loads((Path(__file__).resolve().parents[1] / "pyproject.toml").read_text())[ - "dependency-groups" - ]["release-notes"][0] + release_requirement = tomllib.loads((CALLER / "pyproject.toml").read_text())["dependency-groups"]["release-notes"][ + 0 + ] project.write_text( project.read_text() + f"\n[dependency-groups]\nrelease-notes = [{json.dumps(release_requirement)}]\n" @@ -305,12 +290,6 @@ def action( version: str, **extra_env: str, ) -> subprocess.CompletedProcess[str]: - if ( - name in {"install-locked-tools", "export-build-constraints"} - and (repository / "pylock.toml").exists() - and not (repository / "pyproject.toml").exists() - ): - (repository / "pyproject.toml").write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n') if name == "validate-release-patch" and (prepared := tmp_path / "release.patch").exists(): (tmp_path / "release-patch").mkdir(exist_ok=True) shutil.copyfile(prepared, tmp_path / "release-patch/release.patch") @@ -563,8 +542,6 @@ def _expression( if expression.startswith("steps."): _, step_id, _, output_name = expression.split(".") return step_outputs.get(step_id, {}).get(output_name, "") - if expression == "runner.temp": - return environment["RUNNER_TEMP"] if expression == "github.repository": return environment["GITHUB_REPOSITORY"] if expression == "github.base_ref": diff --git a/tests/export_build_constraints/test_export.py b/tests/export_build_constraints/test_export.py index ba0dfff..fadcdd5 100644 --- a/tests/export_build_constraints/test_export.py +++ b/tests/export_build_constraints/test_export.py @@ -41,7 +41,7 @@ def test_export_build_constraints_accepts_tool_only_project_without_build_group( result = action(tmp_path, tmp_path, action_scripts, "export-build-constraints", outputs, version="") assert result.returncode == 0, result.stderr - assert "setuptools==" in Path(outputs["path"]).read_text() + assert Path(outputs["path"]).read_text() == "" def test_export_build_constraints_writes_empty_file_without_build_selection(tmp_path: Path) -> None: @@ -335,6 +335,9 @@ def test_export_build_constraints_rejects_uncooled_secondary_lock( document = tomlkit.parse(lock.read_text()) document["packages"][0]["wheels"][0]["upload-time"] = datetime(2099, 1, 1, tzinfo=UTC) lock.write_text(tomlkit.dumps(document)) + if groups: + (tmp_path / "pylock.toml").write_bytes((CALLER / "pylock.toml").read_bytes()) + (tmp_path / "pyproject.toml").write_bytes((CALLER / "pyproject.toml").read_bytes()) result = action( tmp_path, tmp_path, @@ -342,7 +345,6 @@ def test_export_build_constraints_rejects_uncooled_secondary_lock( "install-locked-tools" if groups else "export-build-constraints", version="", LOCK="pylock.build.toml", - LOCK_SOURCE="shared", CONSTRAINTS_LOCK="pylock.build.toml", GROUPS=groups, VENV=str(tmp_path / "tools"), diff --git a/tests/install_live_tools/test_install_live_tools.py b/tests/install_live_tools/test_install_live_tools.py new file mode 100644 index 0000000..5d2557f --- /dev/null +++ b/tests/install_live_tools/test_install_live_tools.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any, Final + +import pytest + +_SCRIPT: Final = Path(__file__).resolve().parents[2] / "tools/install_live_tools.sh" + + +@pytest.fixture +def fake_uv(tmp_path: Path) -> Path: + executable = tmp_path / "bin/uv" + executable.parent.mkdir() + executable.write_text( + f"#!{sys.executable}\nimport json, os, pathlib, sys\n" + 'with pathlib.Path(os.environ["UV_CALLS"]).open("a") as stream:\n' + " stream.write(json.dumps({'argv': sys.argv[1:], 'env': dict(os.environ)}) + '\\n')\n" + ) + executable.chmod(0o755) + return executable + + +def _install(tmp_path: Path, fake_uv: Path, **environment: str) -> list[dict[str, Any]]: + calls = tmp_path / "calls.jsonl" + subprocess.run( + [str(_SCRIPT), str(tmp_path / "tools"), "nab==0.0.22", "packaging>=26.3"], + env=os.environ + | {"PATH": f"{fake_uv.parent}{os.pathsep}{os.environ['PATH']}", "UV_CALLS": str(calls)} + | environment, + check=True, + ) + return [json.loads(line) for line in calls.read_text().splitlines()] + + +def test_install_live_tools_applies_the_cutoff(tmp_path: Path, fake_uv: Path) -> None: + venv, install = _install(tmp_path, fake_uv, EXCLUDE_NEWER="7 days") + + assert venv["argv"] == ["venv", "--allow-existing", "--python", "3.15", str(tmp_path / "tools")] + assert install["argv"] == [ + "pip", + "install", + "--python", + str(tmp_path / "tools"), + "--exclude-newer", + "7 days", + "nab==0.0.22", + "packaging>=26.3", + ] + + +def test_install_live_tools_passes_each_package_cutoff(tmp_path: Path, fake_uv: Path) -> None: + _, install = _install( + tmp_path, + fake_uv, + EXCLUDE_NEWER="7 days", + EXCLUDE_NEWER_PACKAGE="nab=2026-10-19T00:00:00Z\n\n packaging=1 day \n", + ) + + assert install["argv"][4:10] == [ + "--exclude-newer", + "7 days", + "--exclude-newer-package", + "nab=2026-10-19T00:00:00Z", + "--exclude-newer-package", + "packaging=1 day", + ] + + +def test_install_live_tools_ignores_inherited_uv_cutoffs(tmp_path: Path, fake_uv: Path) -> None: + _, install = _install( + tmp_path, + fake_uv, + EXCLUDE_NEWER="7 days", + UV_EXCLUDE_NEWER="1970-01-01T00:00:00Z", + UV_EXCLUDE_NEWER_PACKAGE="nab=1970-01-01T00:00:00Z", + ) + + assert not {"UV_EXCLUDE_NEWER", "UV_EXCLUDE_NEWER_PACKAGE"} & install["env"].keys() + + +def test_install_live_tools_requires_a_cutoff(tmp_path: Path, fake_uv: Path) -> None: + result = subprocess.run( + [str(_SCRIPT), str(tmp_path / "tools"), "nab==0.0.22"], + env={name: value for name, value in os.environ.items() if name != "EXCLUDE_NEWER"} + | {"PATH": f"{fake_uv.parent}{os.pathsep}{os.environ['PATH']}", "UV_CALLS": str(tmp_path / "calls.jsonl")}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert "EXCLUDE_NEWER: unbound variable" in result.stderr diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index 172cdd9..9fd956a 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -5,8 +5,7 @@ import subprocess import sys import tomllib -from copy import deepcopy -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from pathlib import Path import pytest @@ -69,52 +68,8 @@ def test_install_locked_tools_selects_job_tools( assert not (tmp_path / "tools/bin" / absent).exists() -def test_backend_constraints_match_shared_lock(tmp_path: Path) -> None: - source = Path(__file__).resolve().parents[2] - generated = subprocess.run( - [ - "uv", - "run", - "--no-project", - "--with", - "packaging>=26.1", - "python", - str(source / "tools/build_constraints.py"), - str(tmp_path / "constraints.txt"), - str(CALLER / "pylock.toml"), - ], - text=True, - capture_output=True, - check=False, - ) - assert generated.returncode == 0, generated.stderr - constraints = (tmp_path / "constraints.txt").read_text() - assert "cython==" in constraints - assert "setuptools==" in constraints - assert "--hash=sha256:" in constraints - - -def test_install_lock_reader_rejects_hash_mismatch(tmp_path: Path, action_scripts: Path) -> None: - action_root = tmp_path / "install-locked-tools" - action_root.mkdir() - lock = (CALLER / "pylock.toml").read_text() - package = next(package for package in tomllib.loads(lock)["packages"] if package["name"] == "packaging") - (tmp_path / "pylock.toml").write_text(lock.replace(package["wheels"][0]["hashes"]["sha256"], "0" * 64, 1)) - result = action( - tmp_path, - tmp_path, - action_scripts, - "install-locked-tools", - version="0.2.0", - GITHUB_ACTION_PATH=str(action_root), - UV_CACHE_DIR=str(tmp_path / "cache"), - ) - assert result.returncode != 0 - assert "Hash mismatch" in result.stderr - - def test_install_locked_tools_reuses_existing_environment(tmp_path: Path, action_scripts: Path, caller: Path) -> None: - environment = {"GROUPS": "lock-tools", "VENV": str(tmp_path / "lock-tools"), "LOCK_SOURCE": "shared"} + environment = {"GROUPS": "lock-tools", "VENV": str(tmp_path / "lock-tools")} first = action(caller, tmp_path, action_scripts, "install-locked-tools", version="", **environment) second = action(caller, tmp_path, action_scripts, "install-locked-tools", version="", **environment) assert first.returncode == second.returncode == 0, first.stderr + second.stderr @@ -207,27 +162,6 @@ def test_install_locked_tools_rejects_missing_group(tmp_path: Path, action_scrip assert "missing lock dependency groups: relesae" in result.stderr -def test_install_locked_tools_checks_caller_release_creator(tmp_path: Path) -> None: - lock = tmp_path / "pylock.toml" - lock.write_text( - 'lock-version = "1.0"\nrequires-python = ">=3.15"\n' - 'dependency-groups = ["release-prepare"]\ncreated-by = "test"\npackages = []\n' - "[tool.nab]\ncreated-at = 2026-10-19T00:00:00Z\n" - ) - source = Path(__file__).resolve().parents[2] - command = [ - "python", - str(source / "tools/check_lock_groups.py"), - str(lock), - "release-prepare", - "--shared-lock", - str(CALLER / "pylock.toml"), - ] - result = subprocess.run(command, text=True, capture_output=True, check=False) - assert result.returncode != 0 - assert "relock: release-creator" in result.stderr - - @pytest.mark.parametrize( ("kind", "message"), [ @@ -531,37 +465,6 @@ def test_install_locked_tools_rejects_invalid_caller_cutoff(tmp_path: Path, valu assert "invalid Nab cutoff for release-creator" in result.stderr -@pytest.mark.parametrize("version", ["0.6.0", "0.6.4"]) -def test_install_locked_tools_rejects_caller_below_shared_floor(tmp_path: Path, version: str) -> None: - source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((CALLER / "pylock.toml").read_text()) - package = next(package for package in document["packages"] if package["name"] == "release-creator") - current_version = str(package["version"]) - package["version"] = version - for artifact in [package["sdist"], *package["wheels"]]: - artifact["url"] = artifact["url"].replace(current_version, version) - artifact["upload-time"] = datetime(2026, 10, 15, tzinfo=UTC) - lock = tmp_path / "pylock.toml" - lock.write_text(tomlkit.dumps(document)) - - result = subprocess.run( - [ - "python", - str(source / "tools/check_lock_groups.py"), - str(lock), - "release-news", - "--shared-lock", - str(CALLER / "pylock.toml"), - ], - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode != 0 - assert f"relock: release-creator {current_version} required" in result.stderr - - def test_install_locked_tools_constraints_only_checks_lock_age(tmp_path: Path, action_scripts: Path) -> None: (tmp_path / "pyproject.toml").write_bytes((CALLER / "pyproject.toml").read_bytes()) document = tomlkit.parse((CALLER / "pylock.toml").read_text()) @@ -581,135 +484,7 @@ def test_install_locked_tools_constraints_only_accepts_no_build_group(tmp_path: ) result = action(tmp_path, tmp_path, action_scripts, "install-locked-tools", version="") assert result.returncode == 0, result.stderr - assert "setuptools==" in next(tmp_path.glob("build-constraints-*.txt")).read_text() - - -def test_caller_version_check_uses_shared_package_marker(tmp_path: Path) -> None: - source = Path(__file__).resolve().parents[2] - shared = tmp_path / "shared.toml" - lock = (CALLER / "pylock.toml").read_text() - marker = next( - package["marker"] for package in tomllib.loads(lock)["packages"] if package["name"] == "release-creator" - ) - shared.write_text(lock.replace(marker.replace('"', '\\"'), '\\"release-shell\\" in dependency_groups')) - caller = tmp_path / "caller.toml" - caller.write_text( - 'lock-version = "1.0"\nrequires-python = ">=3.15"\n' - 'dependency-groups = ["release-shell"]\ncreated-by = "test"\npackages = []\n' - "[tool.nab]\ncreated-at = 2026-10-19T00:00:00Z\n" - ) - result = subprocess.run( - [ - "python", - str(source / "tools/check_lock_groups.py"), - str(caller), - "release-shell", - "--shared-lock", - str(shared), - ], - text=True, - capture_output=True, - check=False, - ) - assert result.returncode != 0 - assert "relock: release-creator" in result.stderr - - -def test_caller_version_check_covers_other_platform(tmp_path: Path) -> None: - source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((CALLER / "pylock.toml").read_text()) - package = next(item for item in document["packages"] if item["name"] == "release-creator") - package["marker"] = '"release-shell" in dependency_groups and sys_platform == "win32"' - shared = tmp_path / "shared.toml" - shared.write_text(tomlkit.dumps(document)) - caller = tmp_path / "caller.toml" - caller.write_text( - 'lock-version = "1.0"\nrequires-python = ">=3.15"\n' - 'dependency-groups = ["release-shell"]\ncreated-by = "test"\npackages = []\n' - "[tool.nab]\ncreated-at = 2026-10-19T00:00:00Z\n" - ) - - result = subprocess.run( - [ - "python", - str(source / "tools/check_lock_groups.py"), - str(caller), - "release-shell", - "--shared-lock", - str(shared), - ], - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode != 0 - assert "relock: release-creator" in result.stderr - - -def test_caller_version_check_uses_highest_shared_platform_floor(tmp_path: Path) -> None: - source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((CALLER / "pylock.toml").read_text()) - package = next(item for item in document["packages"] if item["name"] == "release-creator") - current = deepcopy(package) - current_version = str(package["version"]) - current["marker"] = '"release-news" in dependency_groups and sys_platform == "linux"' - package["marker"] = '"release-news" in dependency_groups and sys_platform == "win32"' - package["version"] = "0.1.0" - for artifact in [package["sdist"], *package["wheels"]]: - artifact["url"] = str(artifact["url"]).replace(current_version, "0.1.0") - artifact["upload-time"] = datetime(2026, 10, 15, tzinfo=UTC) - caller = tmp_path / "caller.toml" - caller.write_text(tomlkit.dumps(document)) - document["packages"].append(current) - shared = tmp_path / "shared.toml" - shared.write_text(tomlkit.dumps(document)) - - result = subprocess.run( - [ - "python", - str(source / "tools/check_lock_groups.py"), - str(caller), - "release-news", - "--shared-lock", - str(shared), - ], - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode != 0 - assert f"relock: release-creator {current_version} required" in result.stderr - - -def test_install_locked_tools_shared_group_ignores_missing_caller_group(tmp_path: Path, action_scripts: Path) -> None: - lock = (CALLER / "pylock.toml").read_text().replace(' "release-attestations",\n', "", 1) - (tmp_path / "pylock.toml").write_text(lock) - assert "release-attestations" not in tomllib.loads(lock)["dependency-groups"] - result = install( - tmp_path, - action_scripts, - tmp_path, - groups="release-attestations", - venv=tmp_path / "tools", - lock_source="shared", - ) - assert result.returncode == 0, result.stderr - assert (tmp_path / "tools/bin/pypi-attestations").is_file() - - -def test_install_locked_tools_rejects_unknown_lock_source(tmp_path: Path, action_scripts: Path) -> None: - result = install( - Path(__file__).resolve().parents[2], - action_scripts, - tmp_path, - groups="release-notes", - venv=tmp_path / "tools", - lock_source="unknown", - ) - assert result.returncode != 0 - assert "unknown lock source: unknown" in result.stderr + assert next(tmp_path.glob("build-constraints-*.txt")).read_text() == "" def test_install_locked_tools_reuses_exporter(tmp_path: Path, action_scripts: Path, caller: Path) -> None: @@ -764,35 +539,6 @@ def test_install_locked_tools_exporter_does_not_build_pyyaml( assert "No module named 'yaml'" in imported.stderr -def test_install_locked_tools_merges_shared_and_caller_build_constraints(tmp_path: Path, action_scripts: Path) -> None: - (tmp_path / "pyproject.toml").write_bytes((CALLER / "pyproject.toml").read_bytes()) - (tmp_path / "pylock.toml").write_bytes((CALLER / "pylock.toml").read_bytes()) - (tmp_path / "pylock.toml").write_text( - (tmp_path / "pylock.toml").read_text() - + """ -[[packages]] -name = "six" -version = "1.16.0" -marker = '\"build\" in dependency_groups' -index = "https://pypi.org/simple/" -[[packages.wheels]] -name = "six-1.16.0-py2.py3-none-any.whl" -url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl" -upload-time = 2021-05-05T14:18:17.237677Z -hashes.sha256 = "8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" -""", - ) - result = install( - tmp_path, - action_scripts, - tmp_path, - groups="release-notes", - venv=tmp_path / "tools", - ) - assert result.returncode == 0, result.stderr - assert "six==1.16.0 " in next(tmp_path.glob("build-constraints-*.txt")).read_text() - - def test_install_locked_tools_accepts_lock_without_build_group(tmp_path: Path, action_scripts: Path) -> None: (tmp_path / "pyproject.toml").write_bytes((CALLER / "pyproject.toml").read_bytes()) document = tomlkit.parse((CALLER / "pylock.toml").read_text()) @@ -800,7 +546,7 @@ def test_install_locked_tools_accepts_lock_without_build_group(tmp_path: Path, a (tmp_path / "pylock.toml").write_text(tomlkit.dumps(document)) result = install(tmp_path, action_scripts, tmp_path, groups="release-check", venv=tmp_path / "tools") assert result.returncode == 0, result.stderr - assert "setuptools==" in next(tmp_path.glob("build-constraints-*.txt")).read_text() + assert next(tmp_path.glob("build-constraints-*.txt")).read_text() == "" def test_install_locked_tools_ignores_resolution_cutoff_for_hashed_lock( @@ -833,37 +579,6 @@ def test_install_locked_tools_ignores_resolution_cutoff_for_hashed_lock( assert installed_version == locked_version -def test_shared_lock_records_upload_times() -> None: - packages = tomllib.loads((CALLER / "pylock.toml").read_text())["packages"] - missing = { - package["name"] - for package in packages - if not any("upload-time" in artifact for artifact in [package.get("sdist", {}), *package.get("wheels", [])]) - } - assert not missing, f"packages without upload times: {sorted(missing)}" - - -def test_shared_lock_respects_seven_day_cutoff() -> None: - overrides = tomllib.loads((CALLER / "pyproject.toml").read_text())["tool"]["release-creator"]["locks"] - floors = {name: datetime.fromisoformat(value) for name, value in overrides.get("exclude-newer-package", {}).items()} - lock = tomllib.loads((CALLER / "pylock.toml").read_text()) - cutoff = datetime.now(UTC) - timedelta(days=7) - packages = lock["packages"] - younger = { - package["name"] - for package in packages - if ( - times := [ - artifact["upload-time"] - for artifact in [package.get("sdist", {}), *package.get("wheels", [])] - if "upload-time" in artifact - ] - ) - and max(times) > floors.get(package["name"], cutoff) - } - assert not younger, f"packages newer than the seven-day cutoff: {sorted(younger)}" - - def install( repository: Path, action_scripts: Path, @@ -871,7 +586,6 @@ def install( *, groups: str, venv: Path, - lock_source: str = "caller", extra_env: dict[str, str] | None = None, ) -> subprocess.CompletedProcess[str]: return action( @@ -882,6 +596,19 @@ def install( version="", GROUPS=groups, VENV=str(venv), - LOCK_SOURCE=lock_source, **(extra_env or {}), ) + + +def test_install_locked_tools_installs_reader_under_cutoff(tmp_path: Path, action_scripts: Path, caller: Path) -> None: + result = install( + caller, + action_scripts, + tmp_path, + groups="release-check", + venv=tmp_path / "tools", + extra_env={"EXCLUDE_NEWER": "2020-01-01T00:00:00Z", "UV_CACHE_DIR": str(tmp_path / "cache")}, + ) + + assert result.returncode != 0 + assert "certifi>=2026.7.22" in result.stderr diff --git a/tests/install_locked_tools/test_secondary_lock.py b/tests/install_locked_tools/test_secondary_lock.py index 1860701..0e9d02f 100644 --- a/tests/install_locked_tools/test_secondary_lock.py +++ b/tests/install_locked_tools/test_secondary_lock.py @@ -7,7 +7,7 @@ from conftest import action -def test_verify_secondary_lock_accepts_cooled_selection(tmp_path: Path, action_scripts: Path) -> None: +def test_verify_secondary_lock_accepts_cooled_selection(tmp_path: Path, caller: Path, action_scripts: Path) -> None: lock = tmp_path / "pylock.zipapp.toml" lock.write_text( 'lock-version = "1.0"\nrequires-python = ">=3.15"\n' @@ -16,12 +16,11 @@ def test_verify_secondary_lock_accepts_cooled_selection(tmp_path: Path, action_s ) result = action( - Path(__file__).resolve().parents[2], + caller, tmp_path, action_scripts, "install-locked-tools", version="", - LOCK_SOURCE="shared", VERIFY_LOCK=str(lock), VERIFY_GROUPS="project-runtime", ) @@ -37,7 +36,7 @@ def test_verify_secondary_lock_accepts_cooled_selection(tmp_path: Path, action_s ], ) def test_verify_secondary_lock_rejects_direct_source( - tmp_path: Path, action_scripts: Path, action_name: str, group: str + tmp_path: Path, caller: Path, action_scripts: Path, action_name: str, group: str ) -> None: lock = tmp_path / "pylock.zipapp.toml" lock.write_text( @@ -59,27 +58,26 @@ def test_verify_secondary_lock_rejects_direct_source( ) result = action( - Path(__file__).resolve().parents[2], + caller, tmp_path, action_scripts, action_name, version="", VERIFY_GROUPS=group, - **({"LOCK_SOURCE": "shared", "VERIFY_LOCK": str(lock)} if group == "project-runtime" else {"LOCK": str(lock)}), + **({"VERIFY_LOCK": str(lock)} if group == "project-runtime" else {"LOCK": str(lock)}), ) assert result.returncode != 0 assert "direct-source package: six" in result.stderr -def test_verify_secondary_lock_requires_group(tmp_path: Path, action_scripts: Path) -> None: +def test_verify_secondary_lock_requires_group(tmp_path: Path, caller: Path, action_scripts: Path) -> None: result = action( - Path(__file__).resolve().parents[2], + caller, tmp_path, action_scripts, "install-locked-tools", version="", - LOCK_SOURCE="shared", VERIFY_LOCK="pylock.zipapp.toml", ) @@ -88,7 +86,7 @@ def test_verify_secondary_lock_requires_group(tmp_path: Path, action_scripts: Pa def test_verify_secondary_lock_rejects_forged_pypi_hash( - tmp_path: Path, action_scripts: Path, pypi_metadata_environment: None + tmp_path: Path, caller: Path, action_scripts: Path, pypi_metadata_environment: None ) -> None: lock = tmp_path / "pylock.zipapp.toml" lock.write_text( @@ -103,12 +101,11 @@ def test_verify_secondary_lock_rejects_forged_pypi_hash( ) result = action( - Path(__file__).resolve().parents[2], + caller, tmp_path, action_scripts, "install-locked-tools", version="", - LOCK_SOURCE="shared", VERIFY_LOCK=str(lock), VERIFY_GROUPS="project-runtime", ) diff --git a/tests/refresh_release_lock/test_refresh.py b/tests/refresh_release_lock/test_refresh.py index b1b665d..67067d5 100644 --- a/tests/refresh_release_lock/test_refresh.py +++ b/tests/refresh_release_lock/test_refresh.py @@ -197,3 +197,21 @@ def _run_step( def _uv_call(root: Path) -> dict[str, Any]: return json.loads((root / "uv-call.json").read_text()) + + +def test_refresh_installs_nab_under_cutoff(lock_project: Path, tmp_path: Path, action_scripts: Path) -> None: + run(lock_project, "git", "add", ".") + run(lock_project, "git", "commit", "-m", "Add the project") + + result = action( + lock_project, + tmp_path, + action_scripts, + "refresh-release-lock", + version="", + EXCLUDE_NEWER="2026-10-01T00:00:00Z", + EXCLUDE_NEWER_PACKAGE="", + ) + + assert result.returncode != 0 + assert "nab==0.0.22" in result.stderr diff --git a/tests/validate_news/test_news.py b/tests/validate_news/test_news.py index e62f4f4..730ff0d 100644 --- a/tests/validate_news/test_news.py +++ b/tests/validate_news/test_news.py @@ -220,3 +220,48 @@ def test_validate_news_rejects_removed_release_lock(repository: Path, tmp_path: assert result.returncode != 0 assert "the release lock was removed" in result.stderr + + +@pytest.mark.parametrize( + ("change", "accepted"), + [ + pytest.param('dependencies = ["six"]', False, id="stale-lock"), + pytest.param("dependencies = [] # unchanged", True, id="current-lock"), + ], +) +def test_validate_news_checks_changed_release_inputs_against_the_lock( + repository: Path, + tmp_path: Path, + action_scripts: Path, + nab_executable: Path, + monkeypatch: pytest.MonkeyPatch, + change: str, + accepted: bool, +) -> None: + monkeypatch.delenv("SSL_CERT_FILE") + project = repository / "pyproject.toml" + project.write_text( + project.read_text() + + '\n[project]\nname = "fixture"\nversion = "0.2.0"\nrequires-python = ">=3.15"\ndependencies = []\n' + + '\n[tool.nab]\nuploaded-prior-to = "P7D"\nbase-group = "project-runtime"\n' + ) + run(repository, str(nab_executable), "lock", "--all-groups", "--all-extras", "--no-cache") + run(repository, "git", "add", ".") + run(repository, "git", "commit", "-m", "Lock the project") + run(repository, "git", "push", "origin", "main") + run(repository, "git", "checkout", "-b", "add-dependency") + project.write_text(project.read_text().replace("dependencies = []", change)) + run(repository, "git", "commit", "-am", "Change the project") + + result = action( + repository, + tmp_path, + action_scripts, + "validate-news", + version="0.2.0", + BASE_REF="main", + SKIP_NEWS="true", + ) + + assert (result.returncode == 0) is accepted, result.stderr + assert ("regenerated Nab lock differs from the committed lock" in result.stderr) is not accepted diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index 41cf9e9..fdb1ab3 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -24,7 +24,7 @@ from packaging.markers import Marker from packaging.pylock import Package, Pylock, PylockValidationError from packaging.utils import canonicalize_name -from packaging.version import InvalidVersion, Version +from packaging.version import InvalidVersion _DURATION: Final = re.compile(r"P(?P[0-9]+)D") @@ -33,7 +33,6 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("lock", type=Path) parser.add_argument("groups", nargs="*") - parser.add_argument("--shared-lock", type=Path) parser.add_argument("--build-constraints", action="store_true") parser.add_argument("--verify-pypi", action="store_true") parser.add_argument("--project", type=Path) @@ -66,14 +65,6 @@ def main() -> None: _check_cooling(lock, groups, nab) if args.verify_pypi: _verify_pypi(lock, groups) - if args.shared_lock: - with args.shared_lock.open("rb") as stream: - shared = Pylock.from_dict(tomllib.load(stream)) - required = max(_selected_release_creators(shared, groups), default=None) - if required is not None: - installed = min(_selected_release_creators(lock, groups), default=None) - if installed is None or installed < required: - parser.error(f"relock: release-creator {required} required") except (IncompleteRead, InvalidVersion, KeyError, OSError, PylockValidationError, TypeError, ValueError) as error: parser.error(str(error)) @@ -320,12 +311,6 @@ def _pypi_files(name: str) -> tuple[str, dict[str, tuple[str | None, str | None, return name, files -def _selected_release_creators(lock: Pylock, groups: set[str]) -> Iterator[Version]: - for package in lock.packages: - if package.name == "release-creator" and (package.marker is None or _possibly_selected(package.marker, groups)): - yield Version(str(package.version)) - - if __name__ == "__main__": main() diff --git a/tools/install_live_tools.sh b/tools/install_live_tools.sh new file mode 100755 index 0000000..cc809c0 --- /dev/null +++ b/tools/install_live_tools.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# The Actions' own tools come from PyPI under a cutoff, so they refresh themselves without a shared lock. +set -euo pipefail +venv=$1 +shift +unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE +options=(--exclude-newer "$EXCLUDE_NEWER") +while IFS= read -r line; do + read -r line <<<"$line" + if [[ -n "$line" ]]; then options+=(--exclude-newer-package "$line"); fi +done <<<"${EXCLUDE_NEWER_PACKAGE:-}" +uv venv --allow-existing --python 3.15 "$venv" +uv pip install --python "$venv" "${options[@]}" "$@" diff --git a/validate-news/action.yml b/validate-news/action.yml index ef88d66..74be124 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -19,6 +19,12 @@ inputs: additional-matrix-implementations: description: Space-separated implementations for the secondary lock default: "" + exclude-newer: + description: Upload cutoff for the tools this Action installs from PyPI + default: 7 days + exclude-newer-package: + description: Newline-separated name=date cutoffs that override exclude-newer for single packages + default: "" runs: using: composite steps: @@ -71,4 +77,6 @@ runs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} mode: check From b58daacd69212d4f70cda0608f94a86eeb4d87d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:08:11 -0700 Subject: [PATCH 09/35] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20feat(release)!:=20ch?= =?UTF-8?q?eck=20the=20lock=20beside=20the=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate-python-release.yaml runs the lock check as its own keyless job in parallel with build. The build job checks the tag inline, including that it resolves to GITHUB_SHA, exports its build constraints and builds; the validate job drops twine check. gw's own release checks the tag inline and re-resolves it before creating the GitHub Release. --- .github/workflows/release.yaml | 41 +++---- .../workflows/validate-python-release.yaml | 58 +++------ README.md | 72 +++++++++-- changelog.d/+release-tag-recheck.change.md | 2 + changelog.d/+shared-lock.breaking.md | 8 +- changelog.d/+tag-release-jobs.breaking.md | 4 + tests/release_workflow/test_graph.py | 33 +++++- tests/validate_python_release/test_tagged.py | 112 +++++++++--------- 8 files changed, 187 insertions(+), 143 deletions(-) create mode 100644 changelog.d/+release-tag-recheck.change.md create mode 100644 changelog.d/+tag-release-jobs.breaking.md diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4a473c4..4b33e74 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -8,23 +8,6 @@ concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: - check: - runs-on: ubuntu-26.04 - permissions: - actions: read - contents: read - pull-requests: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - with: - version: latest-known - python-version: "3.15" - enable-cache: false - - uses: $/check-tagged-source source: runs-on: ubuntu-26.04 permissions: @@ -42,19 +25,22 @@ jobs: version: latest-known python-version: "3.15" enable-cache: false + - name: Verify the exact release tag + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + test "$GITHUB_REF" = "refs/tags/$TAG" + git check-ref-format "refs/tags/$TAG" + test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$GITHUB_SHA" + test "$GITHUB_SHA" = "$(git rev-parse HEAD)" + git merge-base --is-ancestor HEAD origin/main - name: Install release tools env: EXCLUDE_NEWER: 7 days run: | tools/install_live_tools.sh .venv 'release-creator>=2.0.0' uv run --no-project --python .venv python tools/expose_locked_commands.py .venv - - name: Verify tagged source - env: - TAG: ${{ github.ref_name }} - run: | - set -euo pipefail - test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$(git rev-parse HEAD)" - git merge-base --is-ancestor HEAD origin/main - id: previous uses: $/select-previous-tag with: @@ -124,7 +110,7 @@ jobs: path: ${{ runner.temp }}/release-notes.md if-no-files-found: error publish: - needs: [check, source, notes] + needs: [source, notes] runs-on: ubuntu-26.04 environment: release permissions: @@ -134,6 +120,11 @@ jobs: with: artifact-ids: ${{ needs.notes.outputs.artifact-id }} path: ${{ runner.temp }} + - name: Require the tag to still name the release commit + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: test "$(gh api "repos/$GITHUB_REPOSITORY/commits/refs/tags/$TAG" --jq .sha)" = "$GITHUB_SHA" - uses: $/publish-github-release env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index af0b437..9ccf3ad 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -49,16 +49,13 @@ on: permissions: contents: read jobs: - source-check: + lock: runs-on: ubuntu-26.04 permissions: - actions: read contents: read - pull-requests: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 persist-credentials: false ref: ${{ github.sha }} - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 @@ -66,8 +63,9 @@ jobs: version: latest-known python-version: "3.15" enable-cache: false - - uses: $/check-tagged-source + - uses: $/refresh-release-lock with: + mode: check additional-lock: ${{ inputs.additional-lock }} additional-lock-groups: ${{ inputs.additional-lock-groups }} additional-lock-selection: ${{ inputs.additional-lock-selection }} @@ -76,39 +74,7 @@ jobs: additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} exclude-newer: ${{ inputs.exclude-newer }} exclude-newer-package: ${{ inputs.exclude-newer-package }} - constraints: - runs-on: ubuntu-26.04 - outputs: - artifact-id: ${{ steps.upload.outputs.artifact-id }} - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - ref: ${{ github.sha }} - - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - with: - version: latest-known - python-version: "3.15" - enable-cache: false - - id: constraints - uses: $/install-locked-tools - with: - exclude-newer: ${{ inputs.exclude-newer }} - exclude-newer-package: ${{ inputs.exclude-newer-package }} - - name: Stage build constraints - env: - SOURCE: ${{ steps.constraints.outputs.build-constraints }} - run: cp "$SOURCE" "$RUNNER_TEMP/build-constraints.txt" - - id: upload - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: ${{ inputs.artifact-prefix }}build-constraints - path: ${{ runner.temp }}/build-constraints.txt - if-no-files-found: error build: - needs: [source-check, constraints] runs-on: ubuntu-26.04 outputs: artifact-id: ${{ steps.upload.outputs.artifact-id }} @@ -127,17 +93,23 @@ jobs: set -euo pipefail test "$GITHUB_REF" = "refs/tags/$TAG" git check-ref-format "refs/tags/$TAG" - test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$(git rev-parse HEAD)" + test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$GITHUB_SHA" + test "$GITHUB_SHA" = "$(git rev-parse HEAD)" git merge-base --is-ancestor HEAD origin/main - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" enable-cache: false - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + - id: constraints + uses: $/install-locked-tools with: - artifact-ids: ${{ needs.constraints.outputs.artifact-id }} - path: ${{ runner.temp }} + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} + - name: Stage build constraints + env: + SOURCE: ${{ steps.constraints.outputs.build-constraints }} + run: cp "$SOURCE" "$RUNNER_TEMP/build-constraints.txt" - name: Require empty distribution directory run: test ! -d dist || test -z "$(find dist -mindepth 1 -maxdepth 1 -print -quit)" - name: Select build interpreter @@ -194,8 +166,6 @@ jobs: - name: Install distribution validators run: | uv venv --python 3.15 "$RUNNER_TEMP/package-validators" - uv pip install --python "$RUNNER_TEMP/package-validators" 'twine>=7' 'check-wheel-contents>=0.6.3' - - name: Check distribution metadata - run: uv run --no-project --python "$RUNNER_TEMP/package-validators" twine check dist/* + uv pip install --python "$RUNNER_TEMP/package-validators" 'check-wheel-contents>=0.6.3' - name: Check wheel contents run: uv run --no-project --python "$RUNNER_TEMP/package-validators" check-wheel-contents --no-config dist diff --git a/README.md b/README.md index f3da982..1ec9af3 100644 --- a/README.md +++ b/README.md @@ -224,38 +224,84 @@ The tag starts the caller's publisher. Keep PyPI trusted publishing in that top- [PyPI disallows reusable workflows](https://docs.pypi.org/trusted-publishers/troubleshooting/). Repositories with the standard `uv` wheel and sdist build can call `validate-python-release.yaml` in the tag publisher. The [self-release workflow](.github/workflows/release.yaml) shows the GitHub Release path; callers keep their ecosystem -uploads. The read-only build workflow checks the tagged source. The tag caller renders the Release body and has the only -Contents write job that creates the GitHub Release. +uploads. The read-only build workflow checks the tag and the lock. The tag caller renders the Release body and has the +only Contents write job that creates the GitHub Release. ## Validate a Python release -`validate-python-release.yaml` checks the tagged source, exports hashed build constraints in a separate job, builds and -uploads the wheel and sdist, then checks the uploaded files with Twine and check-wheel-contents. The build job runs no -release tool. It requires the tag ref to resolve to the checked-out commit on `main`. +`validate-python-release.yaml` runs two jobs in parallel. The keyless `lock` job runs `refresh-release-lock` in `check` +mode. The `build` job checks the tag inline: `GITHUB_REF` is `refs/tags/`, `git check-ref-format` accepts the tag, +the tag resolves to `GITHUB_SHA`, which is the checked-out commit, and that commit is an ancestor of `origin/main`. The +same job then exports hashed build constraints from the caller lock, builds the wheel and sdist, requires exactly one of +each at the tag's version, and uploads them. A `validate` job runs `check-wheel-contents` on the uploaded files. ```yaml build: uses: >- tox-dev/github-workflows/.github/workflows/validate-python-release.yaml@SHA permissions: - actions: read contents: read - pull-requests: read ``` -The `build` job must grant `actions: read` and `pull-requests: read` as well as `contents: read`. The tagged hook age -check has needed them since 3.0.1, and GitHub refuses to start the workflow when the caller grants less. The caller -needs a `build` group for backend constraints. `source` defaults to `.`. Use `artifact-prefix` when one tag produces -more than one distribution path; each call then writes a distinct `python-package-distributions` artifact. A caller -pinned to a workflow commit runs that commit's local Actions. +The caller needs a `build` group for backend constraints. `source` defaults to `.`. Use `artifact-prefix` when one tag +produces more than one distribution path; each call then writes a distinct `python-package-distributions` artifact. A +caller pinned to a workflow commit runs that commit's local Actions. Pass the secondary lock inputs and the cutoff +inputs as for `refresh-release-lock`. -Publish only after the reusable workflow succeeds. Download the distribution with pinned `download-artifact` +Publish only after the reusable workflow succeeds, so the lock check gates publishing too. Download the distribution +with pinned `download-artifact` [v8.0.2](https://github.com/actions/download-artifact/blob/9000827ccba6bdab643e8b6fd33ac0654aef8333/action.yml#L37-L45), using `artifact-ids: ${{ needs.build.outputs.distributions-artifact-id }}`. The download checks the artifact digest and selects the wheel and sdist from that build run. Render GitHub notes in the caller's tag workflow after fetching GitHub's contributor metadata; this reusable workflow does not render notes. Keep PyPI OIDC in the caller's `release` environment job. +Run the tag workflow under `concurrency` keyed by the tag, without cancelling a run in progress. The release job has +`contents: write` and no checkout. It reconciles the PyPI digests, requires that the tag still resolves to `GITHUB_SHA`, +and then creates the GitHub Release once with `publish-github-release`: + +```yaml +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false +jobs: + release: + needs: [build, pypi, notes] + runs-on: ubuntu-26.04 + permissions: + contents: write + steps: + - uses: astral-sh/setup-uv@SETUP_UV_SHA + with: + python-version: '3.15' + - uses: actions/download-artifact@DOWNLOAD_SHA + with: + artifact-ids: ${{ needs.build.outputs.distributions-artifact-id }} + path: dist + - uses: actions/download-artifact@DOWNLOAD_SHA + with: + artifact-ids: ${{ needs.notes.outputs.artifact-id }} + path: ${{ runner.temp }} + - uses: tox-dev/github-workflows/reconcile-pypi-artifacts@SHA + with: + project: example + version: ${{ github.ref_name }} + dist: dist + - name: Require the tag to still name the release commit + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: test "$(gh api "repos/$GITHUB_REPOSITORY/commits/refs/tags/$TAG" --jq .sha)" = "$GITHUB_SHA" + - uses: tox-dev/github-workflows/publish-github-release@SHA + env: + GH_TOKEN: ${{ github.token }} + with: + tag: ${{ github.ref_name }} + commit: ${{ github.sha }} + title: ${{ github.ref_name }} + notes-file: ${{ runner.temp }}/release-notes.md +``` + ## Build a distribution for a pull request `build-python-distribution` remains available for existing keyless PR build jobs. It checks that `dist/` is empty, diff --git a/changelog.d/+release-tag-recheck.change.md b/changelog.d/+release-tag-recheck.change.md new file mode 100644 index 0000000..4be44bd --- /dev/null +++ b/changelog.d/+release-tag-recheck.change.md @@ -0,0 +1,2 @@ +Make the github-workflows release job check that the tag still resolves to the tagged commit before it creates the +GitHub Release, and check the tag inline instead of through `check-tagged-source`. diff --git a/changelog.d/+shared-lock.breaking.md b/changelog.d/+shared-lock.breaking.md index 238f795..1b46ca9 100644 --- a/changelog.d/+shared-lock.breaking.md +++ b/changelog.d/+shared-lock.breaking.md @@ -1,4 +1,4 @@ -Remove the Actions' shared `pylock.toml`, its weekly refresh, and the `lock-source` input of `install-locked-tools`. -The Actions now install their own tools from PyPI under a seven-day cutoff, and `install-locked-tools` installs only -from the caller's `pylock.toml`. Build constraints come from the caller lock's `build` group alone, without the shared -Cython and setuptools pins. +Remove the Actions' shared `pylock.toml`, its weekly refresh, and the `lock-source` input of `install-locked-tools`. The +Actions now install their own tools from PyPI under a seven-day cutoff, and `install-locked-tools` installs only from +the caller's `pylock.toml`. Build constraints come from the caller lock's `build` group alone, without the shared Cython +and setuptools pins. diff --git a/changelog.d/+tag-release-jobs.breaking.md b/changelog.d/+tag-release-jobs.breaking.md new file mode 100644 index 0000000..47d889a --- /dev/null +++ b/changelog.d/+tag-release-jobs.breaking.md @@ -0,0 +1,4 @@ +Restructure `validate-python-release.yaml`. A keyless `lock` job checks the locks in parallel with `build`, which now +checks the tag inline, including that it resolves to `GITHUB_SHA`, and exports its own build constraints; the +`source-check` and `constraints` jobs and the `build-constraints` artifact are gone. The workflow no longer needs +`actions: read` or `pull-requests: read`, and its `validate` job runs only `check-wheel-contents`, without `twine check`. diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index 869415b..ba02cdb 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -8,6 +8,7 @@ import pytest import yaml +from pytest_httpserver import HTTPServer @pytest.mark.parametrize( @@ -80,9 +81,10 @@ def test_release_writer_has_only_notes_and_reconciler() -> None: workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) publish = workflow["jobs"]["publish"] assert publish["permissions"] == {"contents": "write"} - assert publish["needs"] == ["check", "source", "notes"] - assert [step["uses"] for step in publish["steps"]] == [ + assert publish["needs"] == ["source", "notes"] + assert [step.get("uses", step.get("name")) for step in publish["steps"]] == [ "actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333", + "Require the tag to still name the release commit", "$/publish-github-release", ] assert publish["steps"][-1]["with"]["commit"] == "${{ needs.source.outputs.commit }}" @@ -120,3 +122,30 @@ def test_action_manifests_have_no_empty_environment_mapping() -> None: for path in root.glob("*/action.yml"): action = yaml.safe_load(path.read_text()) assert all("env" not in step or isinstance(step["env"], dict) for step in action["runs"]["steps"]), path + + +@pytest.mark.parametrize(("tag_commit", "accepted"), [("a" * 40, True), ("b" * 40, False)], ids=["same", "moved"]) +def test_release_writer_requires_the_tag_at_the_event_commit( + tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]], tag_commit: str, accepted: bool +) -> None: + server, environment = github_api + server.expect_request("/api/v3/repos/tox-dev/fixture/commits/refs/tags/1.0.0").respond_with_json( + {"sha": tag_commit} + ) + workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) + step = next( + step + for step in workflow["jobs"]["publish"]["steps"] + if step.get("name") == "Require the tag to still name the release commit" + ) + + result = subprocess.run( + ["bash", "-e", "-o", "pipefail", "-c", step["run"]], + cwd=tmp_path, + env=os.environ | environment | {"GITHUB_REPOSITORY": "tox-dev/fixture", "GITHUB_SHA": "a" * 40, "TAG": "1.0.0"}, + text=True, + capture_output=True, + check=False, + ) + + assert (result.returncode == 0) is accepted, result.stderr diff --git a/tests/validate_python_release/test_tagged.py b/tests/validate_python_release/test_tagged.py index 4f39410..2ff6d15 100644 --- a/tests/validate_python_release/test_tagged.py +++ b/tests/validate_python_release/test_tagged.py @@ -5,7 +5,7 @@ import subprocess import sys from pathlib import Path -from typing import Any +from typing import Any, cast import pytest import yaml @@ -47,49 +47,20 @@ def build_constraints(build_repository: Path, tmp_path: Path) -> Path: return path -@pytest.mark.parametrize( - ("workflow_name", "job"), - [ - pytest.param("validate-python-release", "source-check", id="python-release"), - pytest.param("release", "check", id="self-release"), - ], -) -def test_tagged_source_has_history_and_read_access_for_hook_proof(workflow_name: str, job: str) -> None: - workflow = yaml.safe_load( - (Path(__file__).resolve().parents[2] / ".github/workflows" / f"{workflow_name}.yaml").read_text() - ) - source = workflow["jobs"][job] - assert source["permissions"] == {"actions": "read", "contents": "read", "pull-requests": "read"} - assert next(step for step in source["steps"] if "checkout" in step.get("uses", ""))["with"]["fetch-depth"] == 0 - assert "$/check-tagged-source" in [step.get("uses") for step in source["steps"]] +def test_lock_check_runs_keyless_beside_the_build(release_workflow: dict[str, Any]) -> None: + lock = release_workflow["jobs"]["lock"] + check = next(step for step in lock["steps"] if step.get("uses") == "$/refresh-release-lock") + assert lock["permissions"] == {"contents": "read"} + assert check["with"]["mode"] == "check" + assert "needs" not in release_workflow["jobs"]["build"] -def test_tagged_source_rejects_stale_project_requirement(tmp_path: Path) -> None: - source = Path(__file__).resolve().parents[2] - project = tmp_path / "pyproject.toml" - project.write_text( - '[project]\nname = "probe"\nversion = "1.0.0"\nrequires-python = ">=3.15"\ndependencies = []\n' - "[dependency-groups]\nrelease-check = []\n" - "[tool.release-creator.locks]\n" - ) - tool = source / ".release-tools/bin/release-creator" - locked = subprocess.run([str(tool), "locks"], cwd=tmp_path, text=True, capture_output=True, check=False) - assert locked.returncode == 0, locked.stderr - project.write_text(project.read_text().replace("dependencies = []", 'dependencies = ["prek>=999"]')) - selected = subprocess.run( - [sys.executable, str(source / "tools/check_lock_groups.py"), "pylock.toml", "release-check"], - cwd=tmp_path, - text=True, - capture_output=True, - check=False, - ) - freshness = subprocess.run( - [str(tool), "locks", "--check"], cwd=tmp_path, text=True, capture_output=True, check=False - ) - assert selected.returncode == 0, selected.stderr - assert freshness.returncode != 0 - assert "prek>=999" in freshness.stderr +def test_build_exports_constraints_after_checking_the_tag(release_workflow: dict[str, Any]) -> None: + names = [step.get("name", step.get("uses", "")) for step in release_workflow["jobs"]["build"]["steps"]] + + assert names.index("Verify the exact release tag") < names.index("$/install-locked-tools") + assert names.index("$/install-locked-tools") < names.index("Build wheel and sdist") def test_validator_builds_reproducible_distributions_and_checks_them( @@ -119,9 +90,8 @@ def test_validator_builds_reproducible_distributions_and_checks_them( release_workflow, "validate", "Install distribution validators", build_repository, **environment ) assert validators.returncode == 0, validators.stderr - for name in ("Check distribution metadata", "Check wheel contents"): - result = run_release_step(release_workflow, "validate", name, build_repository, **environment) - assert result.returncode == 0, result.stderr + result = run_release_step(release_workflow, "validate", "Check wheel contents", build_repository, **environment) + assert result.returncode == 0, result.stderr def test_validator_rejects_nonempty_distribution_directory(tmp_path: Path, release_workflow: dict[str, Any]) -> None: @@ -162,7 +132,12 @@ def test_validator_rejects_backend_hash_mismatch( ], ) @pytest.mark.parametrize( - "location", [pytest.param("check-tagged-source/action.yml", id="source-check"), pytest.param("build", id="build")] + "location", + [ + pytest.param("check-tagged-source/action.yml", id="source-check"), + pytest.param("validate-python-release:build", id="build"), + pytest.param("release:source", id="self-release"), + ], ) def test_validator_requires_exact_tag( repository: Path, tag_kind: str, event_ref: str, accepted: bool, location: str @@ -177,19 +152,16 @@ def test_validator_requires_exact_tag( run(repository, "git", "tag", "-a", "-m", "Release", "0.2.0") else: run(repository, "git", "tag", "0.2.0") - root = Path(__file__).resolve().parents[2] - steps = ( - yaml.safe_load((root / location).read_text())["runs"]["steps"] - if location.endswith(".yml") - else yaml.safe_load((root / ".github/workflows/validate-python-release.yaml").read_text())["jobs"][location][ - "steps" - ] - ) - verify = next(step for step in steps if step.get("name") == "Verify the exact release tag") + verify = _tag_check(location) result = subprocess.run( ["bash", "-c", verify["run"]], cwd=repository, - env=os.environ | {"GITHUB_REF": event_ref, "TAG": "0.2.0"}, + env=os.environ + | { + "GITHUB_REF": event_ref, + "TAG": "0.2.0", + "GITHUB_SHA": run(repository, "git", "rev-parse", "HEAD").stdout.strip(), + }, text=True, capture_output=True, check=False, @@ -233,3 +205,33 @@ def test_validator_matches_built_version_to_tag( check=False, ) assert (result.returncode == 0) is accepted, result.stderr + + +@pytest.mark.parametrize("location", ["validate-python-release:build", "release:source"], ids=["build", "self-release"]) +def test_validator_requires_tag_at_the_event_commit(repository: Path, location: str) -> None: + run(repository, "git", "tag", "0.2.0") + event_commit = run(repository, "git", "rev-parse", "HEAD").stdout.strip() + run(repository, "git", "commit", "--allow-empty", "-m", "Later change") + run(repository, "git", "push", "origin", "main") + run(repository, "git", "tag", "-f", "0.2.0") + + result = subprocess.run( + ["bash", "-c", _tag_check(location)["run"]], + cwd=repository, + env=os.environ | {"GITHUB_REF": "refs/tags/0.2.0", "TAG": "0.2.0", "GITHUB_SHA": event_commit}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + + +def _tag_check(location: str) -> dict[str, str]: + root = Path(__file__).resolve().parents[2] + if location.endswith(".yml"): + steps = yaml.safe_load((root / location).read_text())["runs"]["steps"] + else: + workflow, job = location.split(":") + steps = yaml.safe_load((root / ".github/workflows" / f"{workflow}.yaml").read_text())["jobs"][job]["steps"] + return cast("dict[str, str]", next(step for step in steps if step.get("name") == "Verify the exact release tag")) From cd5cfe3156482a39e1e9cea4bceee51aff03a28f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:09:25 -0700 Subject: [PATCH 10/35] =?UTF-8?q?=E2=9C=A8=20feat(lock):=20add=20a=20tempo?= =?UTF-8?q?rary=20nab-git-commit=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full commit SHA installs nab from notatallshaw/nab at that commit instead of the release, until a nab release carries the fix turbohtml needs. refresh-release-lock validates it, and the reusable workflows that call the Action forward it. --- .../workflows/prepare-towncrier-release.yaml | 5 +++ .github/workflows/upgrade-release-tools.yaml | 5 +++ .../workflows/validate-python-release.yaml | 5 +++ README.md | 6 +++ changelog.d/+nab-git-commit.feature.md | 2 + changelog.d/+tag-release-jobs.breaking.md | 3 +- refresh-release-lock/action.yml | 15 ++++++- tests/refresh_release_lock/test_refresh.py | 40 +++++++++++++++++++ 8 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 changelog.d/+nab-git-commit.feature.md diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 9281c95..3c221a2 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -46,6 +46,10 @@ on: description: Create a GitHub Deployment for the App-key job type: boolean default: true + nab-git-commit: + description: Temporary full commit SHA of notatallshaw/nab to install instead of the Nab release + type: string + default: "" exclude-newer: description: Upload cutoff for the tools the shared Actions install from PyPI type: string @@ -92,6 +96,7 @@ jobs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} + nab-git-commit: ${{ inputs.nab-git-commit }} exclude-newer: ${{ inputs.exclude-newer }} exclude-newer-package: ${{ inputs.exclude-newer-package }} - uses: $/install-locked-tools diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index 39a2c13..6534a81 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -39,6 +39,10 @@ on: type: string default: |- Refresh the project lock and exact pre-commit hook dependency pins. + nab-git-commit: + description: Temporary full commit SHA of notatallshaw/nab to install instead of the Nab release + type: string + default: "" exclude-newer: description: Upload cutoff for the tools the shared Actions install from PyPI type: string @@ -79,6 +83,7 @@ jobs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} + nab-git-commit: ${{ inputs.nab-git-commit }} exclude-newer: ${{ inputs.exclude-newer }} exclude-newer-package: ${{ inputs.exclude-newer-package }} - id: upload diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index 9ccf3ad..147250c 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -38,6 +38,10 @@ on: description: Space-separated implementations for the secondary lock default: "" type: string + nab-git-commit: + description: Temporary full commit SHA of notatallshaw/nab to install instead of the Nab release + default: "" + type: string exclude-newer: description: Upload cutoff for the tools the shared Actions install from PyPI default: 7 days @@ -72,6 +76,7 @@ jobs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} + nab-git-commit: ${{ inputs.nab-git-commit }} exclude-newer: ${{ inputs.exclude-newer }} exclude-newer-package: ${{ inputs.exclude-newer-package }} build: diff --git a/README.md b/README.md index 1ec9af3..df2179f 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,12 @@ once it is older than the cutoff. Every Action and reusable workflow that instal The tools the caller's release jobs run, such as release-creator, Towncrier, and prek, still come hash-checked from the caller's `pylock.toml`. +`nab-git-commit` is a temporary input of `refresh-release-lock`, `prepare-towncrier-release.yaml`, +`upgrade-release-tools.yaml`, and `validate-python-release.yaml`. It is empty by default. When set, it must be a full +40-character commit SHA, and Nab is installed from `git+https://github.com/notatallshaw/nab@` instead of the +release. That commit gets no cooldown: `exclude-newer` applies only to its dependencies. It exists until a Nab release +carries the needed fix and will be removed then. + ## Check pull request news `validate-news` reads `tool.towncrier` from `pyproject.toml`. It checks every added or modified fragment in the diff --git a/changelog.d/+nab-git-commit.feature.md b/changelog.d/+nab-git-commit.feature.md new file mode 100644 index 0000000..524d2bf --- /dev/null +++ b/changelog.d/+nab-git-commit.feature.md @@ -0,0 +1,2 @@ +Add a temporary `nab-git-commit` input to `refresh-release-lock` and the reusable workflows that call it. A full commit +SHA installs Nab from `notatallshaw/nab` at that commit, with no cooldown, until a Nab release carries the fix. diff --git a/changelog.d/+tag-release-jobs.breaking.md b/changelog.d/+tag-release-jobs.breaking.md index 47d889a..c1b5501 100644 --- a/changelog.d/+tag-release-jobs.breaking.md +++ b/changelog.d/+tag-release-jobs.breaking.md @@ -1,4 +1,5 @@ Restructure `validate-python-release.yaml`. A keyless `lock` job checks the locks in parallel with `build`, which now checks the tag inline, including that it resolves to `GITHUB_SHA`, and exports its own build constraints; the `source-check` and `constraints` jobs and the `build-constraints` artifact are gone. The workflow no longer needs -`actions: read` or `pull-requests: read`, and its `validate` job runs only `check-wheel-contents`, without `twine check`. +`actions: read` or `pull-requests: read`, and its `validate` job runs only `check-wheel-contents`, without +`twine check`. diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index 710798e..d1db553 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -22,6 +22,9 @@ inputs: mode: description: Refresh the lock or check that the committed lock is current default: refresh + nab-git-commit: + description: Temporary full commit SHA of notatallshaw/nab to install instead of the Nab release + default: "" exclude-newer: description: Upload cutoff for the tools this Action installs from PyPI default: 7 days @@ -44,8 +47,13 @@ runs: MATRIX_PYTHON: ${{ inputs.additional-matrix-python }} MATRIX_PLATFORMS: ${{ inputs.additional-matrix-platforms }} MATRIX_IMPLEMENTATIONS: ${{ inputs.additional-matrix-implementations }} + NAB_GIT_COMMIT: ${{ inputs.nab-git-commit }} run: | set -euo pipefail + if [[ -n "$NAB_GIT_COMMIT" && ! "$NAB_GIT_COMMIT" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo '::error::nab-git-commit must be a full 40-character commit SHA' >&2 + exit 1 + fi if [[ -z "$ADDITIONAL_LOCK" && ("$ADDITIONAL_SELECTION" != build-or-groups || -n "$ADDITIONAL_GROUPS$MATRIX_PYTHON$MATRIX_PLATFORMS$MATRIX_IMPLEMENTATIONS") ]]; then @@ -57,7 +65,12 @@ runs: env: EXCLUDE_NEWER: ${{ inputs.exclude-newer }} EXCLUDE_NEWER_PACKAGE: ${{ inputs.exclude-newer-package }} - run: '"$GITHUB_ACTION_PATH/../tools/install_live_tools.sh" "$RUNNER_TEMP/lock-tools" nab==0.0.22' + NAB_GIT_COMMIT: ${{ inputs.nab-git-commit }} + run: | + set -euo pipefail + requirement=nab==0.0.22 + if [[ -n "$NAB_GIT_COMMIT" ]]; then requirement=git+https://github.com/notatallshaw/nab@$NAB_GIT_COMMIT; fi + "$GITHUB_ACTION_PATH/../tools/install_live_tools.sh" "$RUNNER_TEMP/lock-tools" "$requirement" - name: Resolve all project groups and extras shell: bash env: diff --git a/tests/refresh_release_lock/test_refresh.py b/tests/refresh_release_lock/test_refresh.py index 67067d5..b340eed 100644 --- a/tests/refresh_release_lock/test_refresh.py +++ b/tests/refresh_release_lock/test_refresh.py @@ -215,3 +215,43 @@ def test_refresh_installs_nab_under_cutoff(lock_project: Path, tmp_path: Path, a assert result.returncode != 0 assert "nab==0.0.22" in result.stderr + + +@pytest.mark.parametrize("commit", ["abc123", "g" * 40, "a" * 41], ids=["short", "not-hex", "long"]) +def test_refresh_rejects_invalid_nab_commit(tmp_path: Path, action_scripts: Path, commit: str) -> None: + result = action(tmp_path, tmp_path, action_scripts, "refresh-release-lock", version="", NAB_GIT_COMMIT=commit) + + assert result.returncode != 0 + assert "nab-git-commit must be a full 40-character commit SHA" in result.stderr + + +@pytest.mark.parametrize( + ("commit", "requirement"), + [ + pytest.param("", "nab==0.0.22", id="release"), + pytest.param("0123456789abcdef0123456789abcdef01234567", None, id="git-commit"), + ], +) +def test_refresh_installs_selected_nab( + tmp_path: Path, action_scripts: Path, commit: str, requirement: str | None +) -> None: + installer = tmp_path / "tools/install_live_tools.sh" + installer.parent.mkdir() + installer.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >"$RUNNER_TEMP/installed"\n') + installer.chmod(0o755) + (tmp_path / "action").mkdir() + + result = _run_step( + action_scripts, + "Install Nab", + tmp_path, + tmp_path / "bin/uv", + GITHUB_ACTION_PATH=str(tmp_path / "action"), + NAB_GIT_COMMIT=commit, + ) + + assert result.returncode == 0, result.stderr + assert (tmp_path / "installed").read_text().splitlines() == [ + f"{tmp_path}/lock-tools", + requirement or f"git+https://github.com/notatallshaw/nab@{commit}", + ] From c1c1c8980b6e649801faa9c66486e23db1523918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:34:30 -0700 Subject: [PATCH 11/35] =?UTF-8?q?=E2=9C=A8=20feat(lock):=20forward=20nab-g?= =?UTF-8?q?it-commit=20from=20the=20PR=20and=20tag=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate-news and check-tagged-source pass nab-git-commit to refresh-release-lock, so every lock step can use nab from git. --- README.md | 10 +++++----- changelog.d/+nab-git-commit.feature.md | 5 +++-- check-tagged-source/action.yml | 4 ++++ tests/refresh_release_lock/test_refresh.py | 21 +++++++++++++++++++++ validate-news/action.yml | 4 ++++ 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index df2179f..f1e6ceb 100644 --- a/README.md +++ b/README.md @@ -73,11 +73,11 @@ once it is older than the cutoff. Every Action and reusable workflow that instal The tools the caller's release jobs run, such as release-creator, Towncrier, and prek, still come hash-checked from the caller's `pylock.toml`. -`nab-git-commit` is a temporary input of `refresh-release-lock`, `prepare-towncrier-release.yaml`, -`upgrade-release-tools.yaml`, and `validate-python-release.yaml`. It is empty by default. When set, it must be a full -40-character commit SHA, and Nab is installed from `git+https://github.com/notatallshaw/nab@` instead of the -release. That commit gets no cooldown: `exclude-newer` applies only to its dependencies. It exists until a Nab release -carries the needed fix and will be removed then. +`nab-git-commit` is a temporary input of `refresh-release-lock`, `validate-news`, `check-tagged-source`, +`prepare-towncrier-release.yaml`, `upgrade-release-tools.yaml`, and `validate-python-release.yaml`. It is empty by +default. When set, it must be a full 40-character commit SHA, and Nab is installed from +`git+https://github.com/notatallshaw/nab@` instead of the release. That commit gets no cooldown: `exclude-newer` +applies only to its dependencies. It exists until a Nab release carries the needed fix and will be removed then. ## Check pull request news diff --git a/changelog.d/+nab-git-commit.feature.md b/changelog.d/+nab-git-commit.feature.md index 524d2bf..900249c 100644 --- a/changelog.d/+nab-git-commit.feature.md +++ b/changelog.d/+nab-git-commit.feature.md @@ -1,2 +1,3 @@ -Add a temporary `nab-git-commit` input to `refresh-release-lock` and the reusable workflows that call it. A full commit -SHA installs Nab from `notatallshaw/nab` at that commit, with no cooldown, until a Nab release carries the fix. +Add a temporary `nab-git-commit` input to `refresh-release-lock` and to the Actions and reusable workflows that call it. +A full commit SHA installs Nab from `notatallshaw/nab` at that commit, with no cooldown, until a Nab release carries the +fix. diff --git a/check-tagged-source/action.yml b/check-tagged-source/action.yml index d5aec15..1aa39b2 100644 --- a/check-tagged-source/action.yml +++ b/check-tagged-source/action.yml @@ -19,6 +19,9 @@ inputs: additional-matrix-implementations: description: Space-separated implementations for the secondary lock default: "" + nab-git-commit: + description: Temporary full commit SHA of notatallshaw/nab to install instead of the Nab release + default: "" exclude-newer: description: Upload cutoff for the tools this Action installs from PyPI default: 7 days @@ -47,5 +50,6 @@ runs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} + nab-git-commit: ${{ inputs.nab-git-commit }} exclude-newer: ${{ inputs.exclude-newer }} exclude-newer-package: ${{ inputs.exclude-newer-package }} diff --git a/tests/refresh_release_lock/test_refresh.py b/tests/refresh_release_lock/test_refresh.py index b340eed..4f517ce 100644 --- a/tests/refresh_release_lock/test_refresh.py +++ b/tests/refresh_release_lock/test_refresh.py @@ -255,3 +255,24 @@ def test_refresh_installs_selected_nab( f"{tmp_path}/lock-tools", requirement or f"git+https://github.com/notatallshaw/nab@{commit}", ] + + +@pytest.mark.parametrize( + ("path", "steps"), + [ + pytest.param("validate-news/action.yml", ("runs", "steps"), id="validate-news"), + pytest.param("check-tagged-source/action.yml", ("runs", "steps"), id="check-tagged-source"), + pytest.param(".github/workflows/prepare-towncrier-release.yaml", ("jobs", "assemble", "steps"), id="prepare"), + pytest.param(".github/workflows/upgrade-release-tools.yaml", ("jobs", "generate-lock", "steps"), id="upgrade"), + pytest.param(".github/workflows/validate-python-release.yaml", ("jobs", "lock", "steps"), id="python-release"), + ], +) +def test_refresh_callers_forward_nab_commit(path: str, steps: tuple[str, ...]) -> None: + document = yaml.safe_load((Path(__file__).resolve().parents[2] / path).read_text()) + inputs = document["inputs"] if "runs" in document else document[True]["workflow_call"]["inputs"] + for key in steps: + document = document[key] + refresh = next(step for step in document if step.get("uses") == "$/refresh-release-lock") + + assert inputs["nab-git-commit"]["default"] == "" + assert refresh["with"]["nab-git-commit"] == "${{ inputs.nab-git-commit }}" diff --git a/validate-news/action.yml b/validate-news/action.yml index 74be124..f746ae3 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -19,6 +19,9 @@ inputs: additional-matrix-implementations: description: Space-separated implementations for the secondary lock default: "" + nab-git-commit: + description: Temporary full commit SHA of notatallshaw/nab to install instead of the Nab release + default: "" exclude-newer: description: Upload cutoff for the tools this Action installs from PyPI default: 7 days @@ -77,6 +80,7 @@ runs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} + nab-git-commit: ${{ inputs.nab-git-commit }} exclude-newer: ${{ inputs.exclude-newer }} exclude-newer-package: ${{ inputs.exclude-newer-package }} mode: check From 34bba5a372a2f757f13389b9768666173ad079b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:35:13 -0700 Subject: [PATCH 12/35] =?UTF-8?q?=F0=9F=94=A7=20build(tools):=20install=20?= =?UTF-8?q?nab=20from=20a=20version=20floor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refresh-release-lock installs nab>=0.0.22 under the cutoff instead of the exact 0.0.22 pin, so it picks up the newest nab older than seven days, like the other live tools. --- README.md | 5 +++-- changelog.d/+shared-lock.breaking.md | 6 +++--- refresh-release-lock/action.yml | 2 +- tests/conftest.py | 2 +- tests/install_live_tools/test_install_live_tools.py | 6 +++--- tests/refresh_release_lock/test_refresh.py | 4 ++-- 6 files changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f1e6ceb..866756e 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,9 @@ the `build` group, such as profile-guided optimization tools in a `pgo` group. ## Tools the Actions install The Actions install the tools they run themselves, such as Nab, `packaging`, `certifi`, and `pypi-attestations`, from -PyPI with `uv pip install --exclude-newer`, without hashes. There is no shared lock, so a tool release reaches callers -once it is older than the cutoff. Every Action and reusable workflow that installs such a tool takes two inputs: +PyPI with `uv pip install --exclude-newer`, without hashes. Each tool has only a minimum version, such as `nab>=0.0.22`, +and there is no shared lock, so a tool release reaches callers once it is older than the cutoff. Every Action and +reusable workflow that installs such a tool takes two inputs: - `exclude-newer`: the cutoff, `7 days` by default. uv accepts a timestamp or a duration. - `exclude-newer-package`: newline-separated `name=date` pairs, each passed to uv as `--exclude-newer-package`, to let a diff --git a/changelog.d/+shared-lock.breaking.md b/changelog.d/+shared-lock.breaking.md index 1b46ca9..040d89a 100644 --- a/changelog.d/+shared-lock.breaking.md +++ b/changelog.d/+shared-lock.breaking.md @@ -1,4 +1,4 @@ Remove the Actions' shared `pylock.toml`, its weekly refresh, and the `lock-source` input of `install-locked-tools`. The -Actions now install their own tools from PyPI under a seven-day cutoff, and `install-locked-tools` installs only from -the caller's `pylock.toml`. Build constraints come from the caller lock's `build` group alone, without the shared Cython -and setuptools pins. +Actions now install the newest release of each of their own tools, above a minimum version, that is older than a +seven-day cutoff, and `install-locked-tools` installs only from the caller's `pylock.toml`. Build constraints come from +the caller lock's `build` group alone, without the shared Cython and setuptools pins. diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index d1db553..0b85c5d 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -68,7 +68,7 @@ runs: NAB_GIT_COMMIT: ${{ inputs.nab-git-commit }} run: | set -euo pipefail - requirement=nab==0.0.22 + requirement="nab>=0.0.22" if [[ -n "$NAB_GIT_COMMIT" ]]; then requirement=git+https://github.com/notatallshaw/nab@$NAB_GIT_COMMIT; fi "$GITHUB_ACTION_PATH/../tools/install_live_tools.sh" "$RUNNER_TEMP/lock-tools" "$requirement" - name: Resolve all project groups and extras diff --git a/tests/conftest.py b/tests/conftest.py index b8ac1e9..c1a8821 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -145,7 +145,7 @@ def pypi_metadata_environment(pypi_metadata: Path, monkeypatch: pytest.MonkeyPat def nab_executable(tmp_path_factory: pytest.TempPathFactory) -> Path: environment = tmp_path_factory.mktemp("nab-tools") subprocess.run( - [str(Path(__file__).resolve().parents[1] / "tools/install_live_tools.sh"), str(environment), "nab==0.0.22"], + [str(Path(__file__).resolve().parents[1] / "tools/install_live_tools.sh"), str(environment), "nab>=0.0.22"], env=os.environ | {"EXCLUDE_NEWER": "7 days"}, check=True, capture_output=True, diff --git a/tests/install_live_tools/test_install_live_tools.py b/tests/install_live_tools/test_install_live_tools.py index 5d2557f..adb678a 100644 --- a/tests/install_live_tools/test_install_live_tools.py +++ b/tests/install_live_tools/test_install_live_tools.py @@ -28,7 +28,7 @@ def fake_uv(tmp_path: Path) -> Path: def _install(tmp_path: Path, fake_uv: Path, **environment: str) -> list[dict[str, Any]]: calls = tmp_path / "calls.jsonl" subprocess.run( - [str(_SCRIPT), str(tmp_path / "tools"), "nab==0.0.22", "packaging>=26.3"], + [str(_SCRIPT), str(tmp_path / "tools"), "nab>=0.0.22", "packaging>=26.3"], env=os.environ | {"PATH": f"{fake_uv.parent}{os.pathsep}{os.environ['PATH']}", "UV_CALLS": str(calls)} | environment, @@ -48,7 +48,7 @@ def test_install_live_tools_applies_the_cutoff(tmp_path: Path, fake_uv: Path) -> str(tmp_path / "tools"), "--exclude-newer", "7 days", - "nab==0.0.22", + "nab>=0.0.22", "packaging>=26.3", ] @@ -85,7 +85,7 @@ def test_install_live_tools_ignores_inherited_uv_cutoffs(tmp_path: Path, fake_uv def test_install_live_tools_requires_a_cutoff(tmp_path: Path, fake_uv: Path) -> None: result = subprocess.run( - [str(_SCRIPT), str(tmp_path / "tools"), "nab==0.0.22"], + [str(_SCRIPT), str(tmp_path / "tools"), "nab>=0.0.22"], env={name: value for name, value in os.environ.items() if name != "EXCLUDE_NEWER"} | {"PATH": f"{fake_uv.parent}{os.pathsep}{os.environ['PATH']}", "UV_CALLS": str(tmp_path / "calls.jsonl")}, text=True, diff --git a/tests/refresh_release_lock/test_refresh.py b/tests/refresh_release_lock/test_refresh.py index 4f517ce..43106da 100644 --- a/tests/refresh_release_lock/test_refresh.py +++ b/tests/refresh_release_lock/test_refresh.py @@ -214,7 +214,7 @@ def test_refresh_installs_nab_under_cutoff(lock_project: Path, tmp_path: Path, a ) assert result.returncode != 0 - assert "nab==0.0.22" in result.stderr + assert "nab>=0.0.22" in result.stderr @pytest.mark.parametrize("commit", ["abc123", "g" * 40, "a" * 41], ids=["short", "not-hex", "long"]) @@ -228,7 +228,7 @@ def test_refresh_rejects_invalid_nab_commit(tmp_path: Path, action_scripts: Path @pytest.mark.parametrize( ("commit", "requirement"), [ - pytest.param("", "nab==0.0.22", id="release"), + pytest.param("", "nab>=0.0.22", id="release"), pytest.param("0123456789abcdef0123456789abcdef01234567", None, id="git-commit"), ], ) From 346bc77cb0fdfcc80df52ff41042084a036c99cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:36:02 -0700 Subject: [PATCH 13/35] =?UTF-8?q?=F0=9F=94=92=20build(tools):=20install=20?= =?UTF-8?q?the=20lock=20reader=20from=20wheels=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the 4.0.1 --only-binary :all: for the lock reader, now installed live under the cutoff. --- install-locked-tools/action.yml | 2 +- tests/install_locked_tools/test_install.py | 28 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index 9effc4f..173ca54 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -44,7 +44,7 @@ runs: EXCLUDE_NEWER: ${{ inputs.exclude-newer }} EXCLUDE_NEWER_PACKAGE: ${{ inputs.exclude-newer-package }} run: >- - "$GITHUB_ACTION_PATH/../tools/install_live_tools.sh" "$RUNNER_TEMP/lock-reader" 'certifi>=2026.7.22' 'packaging>=26.3' + "$GITHUB_ACTION_PATH/../tools/install_live_tools.sh" "$RUNNER_TEMP/lock-reader" --only-binary :all: 'certifi>=2026.7.22' 'packaging>=26.3' - id: selection name: Select locked groups and build constraints shell: bash diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index 9fd956a..3e67d60 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -10,6 +10,7 @@ import pytest import tomlkit +import yaml from conftest import CALLER, action @@ -612,3 +613,30 @@ def test_install_locked_tools_installs_reader_under_cutoff(tmp_path: Path, actio assert result.returncode != 0 assert "certifi>=2026.7.22" in result.stderr + + +def test_install_locked_tools_installs_only_binary_reader(tmp_path: Path, action_scripts: Path) -> None: + installer = tmp_path / "tools/install_live_tools.sh" + installer.parent.mkdir() + installer.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >"$RUNNER_TEMP/installed"\n') + installer.chmod(0o755) + (tmp_path / "action").mkdir() + steps = yaml.safe_load((Path(__file__).resolve().parents[2] / "install-locked-tools/action.yml").read_text()) + index = next(index for index, step in enumerate(steps["runs"]["steps"]) if step["name"] == "Install lock reader") + + result = subprocess.run( + ["bash", str(action_scripts / f"install-locked-tools-{index}.sh")], + env=os.environ | {"RUNNER_TEMP": str(tmp_path), "GITHUB_ACTION_PATH": str(tmp_path / "action")}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert (tmp_path / "installed").read_text().splitlines() == [ + f"{tmp_path}/lock-reader", + "--only-binary", + ":all:", + "certifi>=2026.7.22", + "packaging>=26.3", + ] From 7884a93cc0f53e70292bb977ed45ea0f66912695 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:36:54 -0700 Subject: [PATCH 14/35] =?UTF-8?q?=F0=9F=94=A5=20feat(release)!:=20drop=20t?= =?UTF-8?q?he=20distribution=20validate=20job?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate-python-release.yaml no longer runs check-wheel-contents; packages check their distributions with their own tox -e pkg_meta. The unused check-wheel-contents and twine test dependencies go too. --- .../workflows/validate-python-release.yaml | 25 ------------------- README.md | 3 ++- changelog.d/+tag-release-jobs.breaking.md | 6 ++--- pyproject.toml | 2 -- tests/validate_python_release/test_tagged.py | 16 ++++-------- 5 files changed, 10 insertions(+), 42 deletions(-) diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index 147250c..662367a 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -149,28 +149,3 @@ jobs: name: ${{ inputs.artifact-prefix }}python-package-distributions path: dist/* if-no-files-found: error - validate: - needs: build - runs-on: ubuntu-26.04 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - ref: ${{ github.sha }} - - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - with: - version: latest-known - python-version: "3.15" - enable-cache: false - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - artifact-ids: ${{ needs.build.outputs.artifact-id }} - path: dist - - name: Install distribution validators - run: | - uv venv --python 3.15 "$RUNNER_TEMP/package-validators" - uv pip install --python "$RUNNER_TEMP/package-validators" 'check-wheel-contents>=0.6.3' - - name: Check wheel contents - run: uv run --no-project --python "$RUNNER_TEMP/package-validators" check-wheel-contents --no-config dist diff --git a/README.md b/README.md index 866756e..b6a35ec 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,8 @@ only Contents write job that creates the GitHub Release. mode. The `build` job checks the tag inline: `GITHUB_REF` is `refs/tags/`, `git check-ref-format` accepts the tag, the tag resolves to `GITHUB_SHA`, which is the checked-out commit, and that commit is an ancestor of `origin/main`. The same job then exports hashed build constraints from the caller lock, builds the wheel and sdist, requires exactly one of -each at the tag's version, and uploads them. A `validate` job runs `check-wheel-contents` on the uploaded files. +each at the tag's version, and uploads them. The workflow does not inspect the built files further; check package +metadata and wheel contents in the package's own `tox -e pkg_meta` run. ```yaml build: diff --git a/changelog.d/+tag-release-jobs.breaking.md b/changelog.d/+tag-release-jobs.breaking.md index c1b5501..ae28532 100644 --- a/changelog.d/+tag-release-jobs.breaking.md +++ b/changelog.d/+tag-release-jobs.breaking.md @@ -1,5 +1,5 @@ Restructure `validate-python-release.yaml`. A keyless `lock` job checks the locks in parallel with `build`, which now checks the tag inline, including that it resolves to `GITHUB_SHA`, and exports its own build constraints; the -`source-check` and `constraints` jobs and the `build-constraints` artifact are gone. The workflow no longer needs -`actions: read` or `pull-requests: read`, and its `validate` job runs only `check-wheel-contents`, without -`twine check`. +`source-check` and `constraints` jobs and the `build-constraints` artifact are gone. The `validate` job, which ran +`twine check` and `check-wheel-contents`, is removed; packages check their distributions with their own +`tox -e pkg_meta`. The workflow no longer needs `actions: read` or `pull-requests: read`. diff --git a/pyproject.toml b/pyproject.toml index aa5baba..43e00bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,6 @@ license-files = [ "LICENSE" ] [dependency-groups] test = [ "certifi", - "check-wheel-contents>=0.6.3", "coverage[toml]>=7.10", "hatchling>=1.28", "pytest>=9", @@ -22,7 +21,6 @@ test = [ "tox>=4.64.7", "tox-uv>=1.36.1", "trustme", - "twine>=7", "ty", ] diff --git a/tests/validate_python_release/test_tagged.py b/tests/validate_python_release/test_tagged.py index 2ff6d15..1c02388 100644 --- a/tests/validate_python_release/test_tagged.py +++ b/tests/validate_python_release/test_tagged.py @@ -56,6 +56,10 @@ def test_lock_check_runs_keyless_beside_the_build(release_workflow: dict[str, An assert "needs" not in release_workflow["jobs"]["build"] +def test_release_workflow_leaves_distribution_checks_to_the_package(release_workflow: dict[str, Any]) -> None: + assert set(release_workflow["jobs"]) == {"lock", "build"} + + def test_build_exports_constraints_after_checking_the_tag(release_workflow: dict[str, Any]) -> None: names = [step.get("name", step.get("uses", "")) for step in release_workflow["jobs"]["build"]["steps"]] @@ -63,7 +67,7 @@ def test_build_exports_constraints_after_checking_the_tag(release_workflow: dict assert names.index("$/install-locked-tools") < names.index("Build wheel and sdist") -def test_validator_builds_reproducible_distributions_and_checks_them( +def test_validator_builds_reproducible_distributions( build_repository: Path, build_constraints: Path, tmp_path: Path, release_workflow: dict[str, Any] ) -> None: assert "hatchling==" in build_constraints.read_text() @@ -82,16 +86,6 @@ def test_validator_builds_reproducible_distributions_and_checks_them( shutil.rmtree(build_repository / "dist") assert set(outputs[0]) == {"fixture-0.2.0-py3-none-any.whl", "fixture-0.2.0.tar.gz"} assert outputs[0] == outputs[1] - for name, content in outputs[0].items(): - path = build_repository / "dist" / name - path.parent.mkdir(exist_ok=True) - path.write_bytes(content) - validators = run_release_step( - release_workflow, "validate", "Install distribution validators", build_repository, **environment - ) - assert validators.returncode == 0, validators.stderr - result = run_release_step(release_workflow, "validate", "Check wheel contents", build_repository, **environment) - assert result.returncode == 0, result.stderr def test_validator_rejects_nonempty_distribution_directory(tmp_path: Path, release_workflow: dict[str, Any]) -> None: From 8b7f182fd0f49acb0d81c0c2229ac453e0fe9496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:37:31 -0700 Subject: [PATCH 15/35] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20fix(deps):=20drop=20?= =?UTF-8?q?hook=20pins=20from=20the=20updater=20PR=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/upgrade-release-tools.yaml | 2 +- changelog.d/+upgrade-body.change.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 changelog.d/+upgrade-body.change.md diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index 6534a81..84a172b 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -38,7 +38,7 @@ on: description: Body for the dependency pull request type: string default: |- - Refresh the project lock and exact pre-commit hook dependency pins. + Refresh the project lock. nab-git-commit: description: Temporary full commit SHA of notatallshaw/nab to install instead of the Nab release type: string diff --git a/changelog.d/+upgrade-body.change.md b/changelog.d/+upgrade-body.change.md new file mode 100644 index 0000000..a168105 --- /dev/null +++ b/changelog.d/+upgrade-body.change.md @@ -0,0 +1,2 @@ +Drop the hook pin mention from the default `pull-request-body` of `upgrade-release-tools.yaml`, which now reads "Refresh +the project lock." From f3dab92a65dc5fea58b36afb30675556bb86c76e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:37:54 -0700 Subject: [PATCH 16/35] =?UTF-8?q?=F0=9F=93=9D=20docs(release):=20drop=20th?= =?UTF-8?q?e=20skip-existing=20upload=20advice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Describe the release job's single publish-github-release call after PyPI and point failed uploads at re-running the failed jobs. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b6a35ec..bed7871 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,7 @@ environment job. Run the tag workflow under `concurrency` keyed by the tag, without cancelling a run in progress. The release job has `contents: write` and no checkout. It reconciles the PyPI digests, requires that the tag still resolves to `GITHUB_SHA`, -and then creates the GitHub Release once with `publish-github-release`: +and then creates the GitHub Release with a single `publish-github-release` call after PyPI, with no separate stage job: ```yaml concurrency: @@ -542,7 +542,7 @@ nonempty. `reconcile-pypi-artifacts` compares the complete validated distribution directory with PyPI's Simple JSON API and verifies a trusted-publisher attestation for each file. Upload the complete validated directory with the pinned PyPI -publisher and `skip-existing: true`. A rerun sends only files missing from PyPI. Run this Action in a read-only job +publisher. If a run fails, use "Re-run failed jobs" so the upload repeats with the same built files. Run this Action after the publisher. A different digest in an already published file, unexpected or yanked file, malformed response, or invalid attestation fails. The GitHub Release writer must depend on this verification. From 26ea31b9c4960aa32c5f923cb03f83d1b6ce5f56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:40:26 -0700 Subject: [PATCH 17/35] =?UTF-8?q?=F0=9F=94=92=20fix(release):=20require=20?= =?UTF-8?q?the=20tag=20at=20GITHUB=5FSHA=20in=20check-tagged-source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/+tagged-source-sha.change.md | 1 + check-tagged-source/action.yml | 3 ++- tests/validate_python_release/test_tagged.py | 6 +++++- 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 changelog.d/+tagged-source-sha.change.md diff --git a/changelog.d/+tagged-source-sha.change.md b/changelog.d/+tagged-source-sha.change.md new file mode 100644 index 0000000..8407459 --- /dev/null +++ b/changelog.d/+tagged-source-sha.change.md @@ -0,0 +1 @@ +Make `check-tagged-source` require that the tag resolves to `GITHUB_SHA`, as the Python release build job does. diff --git a/check-tagged-source/action.yml b/check-tagged-source/action.yml index 1aa39b2..234505d 100644 --- a/check-tagged-source/action.yml +++ b/check-tagged-source/action.yml @@ -39,7 +39,8 @@ runs: set -euo pipefail test "$GITHUB_REF" = "refs/tags/$TAG" git check-ref-format "refs/tags/$TAG" - test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$(git rev-parse HEAD)" + test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$GITHUB_SHA" + test "$GITHUB_SHA" = "$(git rev-parse HEAD)" git merge-base --is-ancestor HEAD origin/main - uses: $/refresh-release-lock with: diff --git a/tests/validate_python_release/test_tagged.py b/tests/validate_python_release/test_tagged.py index 1c02388..a325176 100644 --- a/tests/validate_python_release/test_tagged.py +++ b/tests/validate_python_release/test_tagged.py @@ -201,7 +201,11 @@ def test_validator_matches_built_version_to_tag( assert (result.returncode == 0) is accepted, result.stderr -@pytest.mark.parametrize("location", ["validate-python-release:build", "release:source"], ids=["build", "self-release"]) +@pytest.mark.parametrize( + "location", + ["check-tagged-source/action.yml", "validate-python-release:build", "release:source"], + ids=["source-check", "build", "self-release"], +) def test_validator_requires_tag_at_the_event_commit(repository: Path, location: str) -> None: run(repository, "git", "tag", "0.2.0") event_commit = run(repository, "git", "rev-parse", "HEAD").stdout.strip() From ceeca0e94a5488d087ebe3a318adf33ba077a2a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:41:12 -0700 Subject: [PATCH 18/35] =?UTF-8?q?=F0=9F=94=92=20fix(lock):=20keep=20the=20?= =?UTF-8?q?primary=20lock's=20target=20environments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refresh-release-lock runs check_nab_lock.py --environments-only after regenerating the primary lock in both modes, as it does for the secondary lock. --- changelog.d/+primary-environments.change.md | 1 + refresh-release-lock/action.yml | 8 ++++---- tests/refresh_release_lock/test_refresh.py | 17 +++++++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 changelog.d/+primary-environments.change.md diff --git a/changelog.d/+primary-environments.change.md b/changelog.d/+primary-environments.change.md new file mode 100644 index 0000000..946dc89 --- /dev/null +++ b/changelog.d/+primary-environments.change.md @@ -0,0 +1 @@ +Reject a primary lock refresh that changes the lock's target environments, as the secondary lock refresh already does. diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index 0b85c5d..58c3927 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -79,17 +79,17 @@ runs: set -euo pipefail unset SSL_CERT_FILE SSL_CERT_DIR case "$MODE" in - check) - refresh=() - cp pylock.toml "$RUNNER_TEMP/lock-before.toml" - ;; + check) refresh=() ;; refresh) refresh=(--upgrade) ;; *) echo "::error::invalid lock mode: $MODE" >&2 exit 1 ;; esac + cp pylock.toml "$RUNNER_TEMP/lock-before.toml" uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock --all-groups --all-extras --no-cache "${refresh[@]}" + python3 "$GITHUB_ACTION_PATH/../tools/check_nab_lock.py" --environments-only \ + "$RUNNER_TEMP/lock-before.toml" pylock.toml if [[ "$MODE" == check ]]; then python3 "$GITHUB_ACTION_PATH/../tools/check_nab_lock.py" "$RUNNER_TEMP/lock-before.toml" pylock.toml fi diff --git a/tests/refresh_release_lock/test_refresh.py b/tests/refresh_release_lock/test_refresh.py index 43106da..baf2d3e 100644 --- a/tests/refresh_release_lock/test_refresh.py +++ b/tests/refresh_release_lock/test_refresh.py @@ -276,3 +276,20 @@ def test_refresh_callers_forward_nab_commit(path: str, steps: tuple[str, ...]) - assert inputs["nab-git-commit"]["default"] == "" assert refresh["with"]["nab-git-commit"] == "${{ inputs.nab-git-commit }}" + + +@pytest.mark.parametrize("mode", ["check", "refresh"]) +def test_refresh_rejects_changed_primary_environments(tmp_path: Path, action_scripts: Path, mode: str) -> None: + (tmp_path / "pylock.toml").write_text(_LOCK) + retargeting = tmp_path / "bin/uv" + retargeting.parent.mkdir() + retargeting.write_text( + f"#!{sys.executable}\nimport pathlib\nlock = pathlib.Path('pylock.toml')\n" + "lock.write_text(lock.read_text().replace('3.15', '3.14'))\n" + ) + retargeting.chmod(0o755) + + result = _run_step(action_scripts, "Resolve all project groups and extras", tmp_path, retargeting, MODE=mode) + + assert result.returncode != 0 + assert "regenerated Nab lock changed its target environments" in result.stderr From 4ad28274b428872b3b8fbac56fa222f9da5f98a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:42:40 -0700 Subject: [PATCH 19/35] =?UTF-8?q?=F0=9F=90=9B=20fix(news):=20allow=20remov?= =?UTF-8?q?ing=20a=20lock=20nothing=20produces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate-news accepts a pylock.toml removal when the project has no [tool.nab] table and no additional lock is configured. --- changelog.d/+lock-removal.change.md | 2 ++ tests/validate_news/test_news.py | 21 +++++++++++++++++---- validate-news/action.yml | 6 +++++- 3 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 changelog.d/+lock-removal.change.md diff --git a/changelog.d/+lock-removal.change.md b/changelog.d/+lock-removal.change.md new file mode 100644 index 0000000..b75bd35 --- /dev/null +++ b/changelog.d/+lock-removal.change.md @@ -0,0 +1,2 @@ +Let `validate-news` accept a pull request that removes `pylock.toml` when the project has no `[tool.nab]` table and no +`additional-lock` is configured, so nothing would produce a lock. diff --git a/tests/validate_news/test_news.py b/tests/validate_news/test_news.py index 730ff0d..dc54021 100644 --- a/tests/validate_news/test_news.py +++ b/tests/validate_news/test_news.py @@ -194,8 +194,21 @@ def test_validate_news_detects_release_input_change( assert f"changed={changed}\n" in output.read_text() -def test_validate_news_rejects_removed_release_lock(repository: Path, tmp_path: Path, action_scripts: Path) -> None: +@pytest.mark.parametrize( + ("nab_config", "additional_lock", "accepted"), + [ + pytest.param(True, "", False, id="nab-config"), + pytest.param(False, "pylock.zipapp.toml", False, id="secondary-lock"), + pytest.param(False, "", True, id="no-lock-producer"), + ], +) +def test_validate_news_checks_removed_release_lock( + repository: Path, tmp_path: Path, action_scripts: Path, nab_config: bool, additional_lock: str, accepted: bool +) -> None: (repository / "pylock.toml").write_text("lock-version = '1.0'\n") + if nab_config: + project = repository / "pyproject.toml" + project.write_text(project.read_text() + '\n[tool.nab]\nuploaded-prior-to = "P7D"\n') run(repository, "git", "add", ".") run(repository, "git", "commit", "-m", "Add release lock") run(repository, "git", "push", "origin", "main") @@ -209,7 +222,7 @@ def test_validate_news_rejects_removed_release_lock(repository: Path, tmp_path: env=os.environ | { "BASE_REF": "main", - "ADDITIONAL_LOCK": "", + "ADDITIONAL_LOCK": additional_lock, "GITHUB_OUTPUT": str(tmp_path / "lock-change-output"), "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "validate-news"), }, @@ -218,8 +231,8 @@ def test_validate_news_rejects_removed_release_lock(repository: Path, tmp_path: check=False, ) - assert result.returncode != 0 - assert "the release lock was removed" in result.stderr + assert (result.returncode == 0) is accepted, result.stderr + assert ("the release lock was removed" in result.stderr) is not accepted @pytest.mark.parametrize( diff --git a/validate-news/action.yml b/validate-news/action.yml index f746ae3..2fed661 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -62,7 +62,11 @@ runs: exit 0 fi if [[ ! -f pylock.toml ]]; then - if git cat-file -e "origin/$BASE_REF:pylock.toml" 2>/dev/null; then + # Removing the lock is fine only once nothing is left that would produce one. + if git cat-file -e "origin/$BASE_REF:pylock.toml" 2>/dev/null && { [[ -n "$ADDITIONAL_LOCK" ]] || { + [[ -f pyproject.toml ]] && + python3 -c 'import sys, tomllib; sys.exit("nab" not in tomllib.load(open("pyproject.toml", "rb")).get("tool", {}))' + }; }; then echo '::error::the release lock was removed' >&2 exit 1 fi From b18e57a47012f89a7d4bd0326ea9d80258da7063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:44:11 -0700 Subject: [PATCH 20/35] =?UTF-8?q?=F0=9F=94=A5=20refactor(lock)!:=20export?= =?UTF-8?q?=20constraints=20from=20one=20lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_constraints.py loses --shared-policy and the two-lock merge, which only served the removed shared lock. --- .../+single-constraints-lock.breaking.md | 2 + tests/export_build_constraints/test_export.py | 75 +++------------ tools/build_constraints.py | 96 +++++++------------ 3 files changed, 49 insertions(+), 124 deletions(-) create mode 100644 changelog.d/+single-constraints-lock.breaking.md diff --git a/changelog.d/+single-constraints-lock.breaking.md b/changelog.d/+single-constraints-lock.breaking.md new file mode 100644 index 0000000..f767d78 --- /dev/null +++ b/changelog.d/+single-constraints-lock.breaking.md @@ -0,0 +1,2 @@ +Remove the `--shared-policy` option and two-lock merging from `tools/build_constraints.py`; it now exports the build +constraints of one lock. diff --git a/tests/export_build_constraints/test_export.py b/tests/export_build_constraints/test_export.py index fadcdd5..6c2e661 100644 --- a/tests/export_build_constraints/test_export.py +++ b/tests/export_build_constraints/test_export.py @@ -5,6 +5,7 @@ import sys import tomllib from collections.abc import Callable +from copy import deepcopy from datetime import UTC, datetime from pathlib import Path from textwrap import dedent @@ -92,47 +93,6 @@ def test_export_build_constraints_covers_release_runners(tmp_path: Path, platfor assert "setuptools==" in output.read_text() -@pytest.mark.parametrize( - ("version", "accepted"), - [pytest.param("81.0.0", True, id="compatible"), pytest.param("79.0.0", False, id="incompatible")], -) -def test_export_build_constraints_prefers_compatible_caller_build_pin( - tmp_path: Path, version: str, accepted: bool -) -> None: - source = Path(__file__).resolve().parents[2] - shared = CALLER / "pylock.toml" - caller = tmp_path / "pylock.toml" - document = tomlkit.parse(shared.read_text()) - for package in document["packages"]: - if package["name"] == "setuptools": - current = package["version"] - package["version"] = version - for wheel in package["wheels"]: - wheel["url"] = wheel["url"].replace(current, version) - package["sdist"]["url"] = package["sdist"]["url"].replace(current, version) - caller.write_text(tomlkit.dumps(document)) - output = tmp_path / "constraints.txt" - result = subprocess.run( - [ - sys.executable, - str(source / "tools/build_constraints.py"), - "--shared-policy", - str(CALLER / "pyproject.toml"), - str(output), - str(shared), - str(caller), - ], - text=True, - capture_output=True, - check=False, - ) - assert (result.returncode == 0) == accepted - if accepted: - assert f"setuptools=={version} " in output.read_text() - else: - assert "violates setuptools>=80" in result.stderr - - @pytest.mark.parametrize( ("platform", "targets"), [ @@ -167,12 +127,6 @@ def test_export_build_constraints_selects_explicit_targets(tmp_path: Path, platf @pytest.mark.parametrize( ("arguments", "message"), [ - pytest.param(["{output}", "{lock}", "{lock}", "{lock}"], "at most one shared", id="three-locks"), - pytest.param( - ["--shared-policy", "{project}", "{output}", "{lock}"], - "shared-policy requires a shared and caller lock", - id="policy-without-caller", - ), pytest.param( ["--target-platform", "unknown", "--targets", "3.15.0:cpython", "{output}", "{lock}"], "unknown target platform", @@ -254,33 +208,28 @@ def test_export_build_constraints_rejects_untrusted_build_packages(tmp_path: Pat assert message in result.stderr -def test_export_build_constraints_rejects_conflicting_versions_without_policy(tmp_path: Path) -> None: +def test_export_build_constraints_rejects_conflicting_versions(tmp_path: Path) -> None: source = Path(__file__).resolve().parents[2] - shared = CALLER / "pylock.toml" - caller = tmp_path / "pylock.toml" - document = tomlkit.parse(shared.read_text()) + document = tomlkit.parse((CALLER / "pylock.toml").read_text()) package = next(package for package in document["packages"] if package["name"] == "setuptools") - current = package["version"] - package["version"] = "81.0.0" - for artifact in [package["sdist"], *package["wheels"]]: + other = deepcopy(package) + current = other["version"] + other["version"] = "81.0.0" + for artifact in [other["sdist"], *other["wheels"]]: artifact["url"] = artifact["url"].replace(current, "81.0.0") - caller.write_text(tomlkit.dumps(document)) + document["packages"].append(other) + lock = tmp_path / "pylock.toml" + lock.write_text(tomlkit.dumps(document)) result = subprocess.run( - [ - sys.executable, - str(source / "tools/build_constraints.py"), - str(tmp_path / "constraints.txt"), - str(shared), - str(caller), - ], + [sys.executable, str(source / "tools/build_constraints.py"), str(tmp_path / "constraints.txt"), str(lock)], text=True, capture_output=True, check=False, ) assert result.returncode != 0 - assert "conflicting build dependency versions for setuptools" in result.stderr + assert "Multiple packages with the name 'setuptools'" in result.stderr def test_export_build_constraints_action_selects_secondary_lock(tmp_path: Path, action_scripts: Path) -> None: diff --git a/tools/build_constraints.py b/tools/build_constraints.py index 3baf3ab..62f21ef 100644 --- a/tools/build_constraints.py +++ b/tools/build_constraints.py @@ -8,7 +8,6 @@ from packaging.markers import Environment, default_environment from packaging.pylock import Pylock, PylockSelectError, PylockValidationError -from packaging.requirements import Requirement from packaging.tags import Tag from packaging.utils import canonicalize_name, parse_wheel_filename from packaging.version import Version @@ -17,23 +16,15 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("output", type=Path) - parser.add_argument("locks", nargs="+", type=Path) - parser.add_argument("--shared-policy", type=Path) + parser.add_argument("lock", type=Path) parser.add_argument("--target-platform") parser.add_argument("--targets") args = parser.parse_args() - if len(args.locks) > 2: - parser.error("at most one shared and one caller lock are supported") - if args.shared_policy and len(args.locks) != 2: - parser.error("shared-policy requires a shared and caller lock") try: - locks = [] - for path in args.locks: - with path.open("rb") as stream: - locks.append(Pylock.from_dict(tomllib.load(stream))) - requirements = _shared_requirements(args.shared_policy) if args.shared_policy else {} + with args.lock.open("rb") as stream: + lock = Pylock.from_dict(tomllib.load(stream)) targets = _target_environments(args.target_platform, args.targets) if args.targets else [None] - selected_by_target = [_selected_build(locks, environment, requirements) for environment in targets] + selected_by_target = [_selected_build(lock, environment) for environment in targets] versions = [{name: version for name, (version, _) in selected.items()} for selected in selected_by_target] if any(other != versions[0] for other in versions[1:]): parser.error("build package versions differ across requested targets") @@ -57,13 +48,6 @@ def main() -> None: parser.error(str(error)) -def _shared_requirements(path: Path) -> dict[str, Requirement]: - with path.open("rb") as stream: - project = tomllib.load(stream) - requirements = (Requirement(value) for value in project["dependency-groups"]["build"]) - return {canonicalize_name(requirement.name): requirement for requirement in requirements} - - def _target_environments(platform: str, values: str) -> list[Environment]: platforms = { "linux_x86_64": ("linux", "Linux", "x86_64", "posix"), @@ -102,48 +86,38 @@ def _target_environments(platform: str, values: str) -> list[Environment]: return environments -def _selected_build( - locks: list[Pylock], environment: Environment | None, requirements: dict[str, Requirement] -) -> dict[str, tuple[Version, set[str]]]: +def _selected_build(lock: Pylock, environment: Environment | None) -> dict[str, tuple[Version, set[str]]]: + if "build" in (lock.dependency_groups or ()): + groups = {"build"} + elif lock.dependency_groups: + return {} + else: + groups = set() + # Artifact choice is left to the installer; constraints carry every locked hash. + tags: list[Tag] | None = None + if environment is not None: + tags = sorted( + { + tag + for package in lock.packages + for wheel in (package.wheels if package.wheels is not None else ()) + for tag in parse_wheel_filename(wheel.filename)[3] + }, + key=str, + ) selected: dict[str, tuple[Version, set[str]]] = {} - for index, lock in enumerate(locks): - if "build" not in (lock.dependency_groups or ()): - if (lock.dependency_groups is not None and len(lock.dependency_groups) > 0) or len(locks) == 2: - continue - groups: set[str] = set() - else: - groups = {"build"} - # Artifact choice is left to the installer; constraints carry every locked hash. - tags: list[Tag] | None = None - if environment is not None: - tags = sorted( - { - tag - for package in lock.packages - for wheel in (package.wheels if package.wheels is not None else ()) - for tag in parse_wheel_filename(wheel.filename)[3] - }, - key=str, - ) - for package, _ in lock.select(environment=environment, tags=tags, dependency_groups=groups): - if package.version is None or package.index is None: - raise ValueError(f"build dependency {package.name} must be an index version") - name = canonicalize_name(package.name) - hashes = { - artifact.hashes["sha256"] - for artifact in [*(package.wheels or ()), *((package.sdist,) if package.sdist else ())] - if "sha256" in artifact.hashes - } - if not hashes: - raise ValueError(f"build dependency {package.name} has no SHA256 hash") - if (existing := selected.get(name)) is not None and existing[0] != package.version: - if index != 1 or not (requirement := requirements.get(name)): - raise ValueError(f"conflicting build dependency versions for {name}") - if package.version not in requirement.specifier: - raise ValueError(f"caller build dependency {name}=={package.version} violates {requirement}") - elif existing is not None: - hashes.update(existing[1]) - selected[name] = (package.version, hashes) + for package, _ in lock.select(environment=environment, tags=tags, dependency_groups=groups): + if package.version is None or package.index is None: + raise ValueError(f"build dependency {package.name} must be an index version") + name = canonicalize_name(package.name) + hashes = { + artifact.hashes["sha256"] + for artifact in [*(package.wheels or ()), *((package.sdist,) if package.sdist else ())] + if "sha256" in artifact.hashes + } + if not hashes: + raise ValueError(f"build dependency {package.name} has no SHA256 hash") + selected[name] = (package.version, hashes) return selected From 292feaf7231c6980f5ef3b52ce2f6c6482a7f7d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:46:48 -0700 Subject: [PATCH 21/35] =?UTF-8?q?=F0=9F=94=A5=20feat(lock)!:=20leave=20loc?= =?UTF-8?q?k=20verification=20to=20nab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_lock_groups.py only checks that the selected groups exist; the PyPI comparison, cooling and per-package exception logic go with their tests and the fake PyPI plumbing. Nab's regenerate-and-compare check now guards the lock on PRs, on tags, and in a new keyless lock job that the prepare workflow's assembly waits for. --- .../workflows/prepare-towncrier-release.yaml | 27 +- README.md | 22 +- changelog.d/+cutoff-table.breaking.md | 3 +- changelog.d/+nab-verification.breaking.md | 4 + install-locked-tools/action.yml | 11 +- tests/build_python_distribution/test_build.py | 2 +- tests/conftest.py | 80 +-- tests/coverage_bootstrap/sitecustomize.py | 21 - tests/export_build_constraints/test_export.py | 50 -- tests/fixtures/caller/pyproject.toml | 6 - tests/install_locked_tools/test_groups.py | 75 +++ tests/install_locked_tools/test_install.py | 294 --------- tests/install_locked_tools/test_metadata.py | 586 ------------------ .../test_secondary_lock.py | 74 --- .../test_workspace_sources.py | 92 --- tests/refresh_release_lock/test_refresh.py | 2 +- tools/check_lock_groups.py | 283 +-------- 17 files changed, 121 insertions(+), 1511 deletions(-) create mode 100644 changelog.d/+nab-verification.breaking.md create mode 100644 tests/install_locked_tools/test_groups.py delete mode 100644 tests/install_locked_tools/test_metadata.py delete mode 100644 tests/install_locked_tools/test_workspace_sources.py diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 3c221a2..6f28909 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -67,19 +67,14 @@ concurrency: group: prepare-release-${{ github.repository }} cancel-in-progress: false jobs: - assemble: + lock: if: github.ref == 'refs/heads/main' runs-on: ubuntu-26.04 permissions: contents: read - pull-requests: read - outputs: - version: ${{ steps.prepare.outputs.version }} - artifact-id: ${{ steps.patch.outputs.artifact-id }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 persist-credentials: false ref: ${{ github.sha }} - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 @@ -99,6 +94,26 @@ jobs: nab-git-commit: ${{ inputs.nab-git-commit }} exclude-newer: ${{ inputs.exclude-newer }} exclude-newer-package: ${{ inputs.exclude-newer-package }} + assemble: + needs: lock + runs-on: ubuntu-26.04 + permissions: + contents: read + pull-requests: read + outputs: + version: ${{ steps.prepare.outputs.version }} + artifact-id: ${{ steps.patch.outputs.artifact-id }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + version: latest-known + python-version: "3.15" + enable-cache: false - uses: $/install-locked-tools with: groups: release-prepare diff --git a/README.md b/README.md index bed7871..3adb451 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,10 @@ selected release groups. The Action rejects missing groups and leaves tracked fi constraints generated from the lock's `build` group under the runner's temporary directory. A lock without a `build` group exports an empty constraints file. -Every selected package must come from PyPI with the recorded hashes and upload times, and must have been uploaded at -least seven days before the lock was created. A package that the caller's `[tool.nab.packages.]` or -`[[tool.nab.package-rules]]` table gives an `uploaded-prior-to` override may be as new as that override allows: a -timestamp, a `PnD` duration before the lock's creation, or `false` for no cutoff. +The Action only checks that the selected groups exist in the lock; installs are hash-checked. Lock integrity comes from +Nab: `refresh-release-lock` in `check` mode regenerates the lock and fails if it differs. It runs on pull requests +through `validate-news`, on tags in the parallel lock job, and in a keyless job of the prepare workflow before the +release patch is assembled. The cutoff and any per-package exception live in the caller's `[tool.nab]` table. Select the groups each job needs. The shared workflows use `release-prepare` and `release-validate`. A full lock records each group and extra, but a new combination still needs an install check before a caller uses it. @@ -26,8 +26,8 @@ each group and extra, but a new combination still needs an install check before release tools. - `constraints-lock`: lock used to export build constraints; defaults to `pylock.toml`. Use a secondary lock when the build matrix differs from the release-tool matrix. Without `groups`, the Action checks only this lock. -- `verify-lock` and `verify-groups`: check another lock's selected packages against PyPI and the seven-day cutoff - without installing them. Use this before bundling packages from a separate runtime lock, such as a zipapp. +- `verify-lock` and `verify-groups`: check that another lock has the selected groups without installing them. Use this + before bundling packages from a separate runtime lock, such as a zipapp. - `target-platform` and `targets`: Nab platform ID and space-separated `full-version:implementation` pairs for build constraints. Pass both when exporting constraints for a specific build target; otherwise the runner selects the target. @@ -343,11 +343,11 @@ build package versions; otherwise the Action fails instead of writing an ambiguo ## Prepare and tag with one reusable workflow -`prepare-towncrier-release.yaml` runs three jobs. `assemble` selects a tag and uploads a Towncrier patch without the App -key. `validate` calculates the next tag again from the checked-out source, then applies the uploaded artifact and runs -`prek`. `push` downloads that artifact by ID, checks its paths and file modes, reads the caller's `release-auth` -environment, and uses a repository-scoped App token to push the release commit and tag together. Only a dispatch from -`main` can reach the writer. +`prepare-towncrier-release.yaml` runs four jobs. The keyless `lock` job runs `refresh-release-lock` in `check` mode. +`assemble` then selects a tag and uploads a Towncrier patch without the App key. `validate` calculates the next tag +again from the checked-out source, then applies the uploaded artifact and runs `prek`. `push` downloads that artifact by +ID, checks its paths and file modes, reads the caller's `release-auth` environment, and uses a repository-scoped App +token to push the release commit and tag together. Only a dispatch from `main` can reach the writer. ```yaml jobs: diff --git a/changelog.d/+cutoff-table.breaking.md b/changelog.d/+cutoff-table.breaking.md index 6b059d8..d717aa7 100644 --- a/changelog.d/+cutoff-table.breaking.md +++ b/changelog.d/+cutoff-table.breaking.md @@ -1,3 +1,2 @@ Remove the reviewed cutoff table, the recent cutoff check, the cutoff expiry, and the `verify-cutoff-patch` Action. -Callers own their per-package `uploaded-prior-to` exceptions in `[tool.nab]`; the release lock check honours them, and -nothing checks or expires them. +Callers own their per-package `uploaded-prior-to` exceptions in `[tool.nab]`, and gw neither checks nor expires them. diff --git a/changelog.d/+nab-verification.breaking.md b/changelog.d/+nab-verification.breaking.md new file mode 100644 index 0000000..421e59a --- /dev/null +++ b/changelog.d/+nab-verification.breaking.md @@ -0,0 +1,4 @@ +Stop checking caller locks against PyPI metadata and upload times in `install-locked-tools`. `check_lock_groups.py` now +only checks that the selected groups exist, and Nab's regenerate-and-compare check in `refresh-release-lock` guards the +lock on pull requests, on tags, and in a new keyless `lock` job of `prepare-towncrier-release.yaml` that runs before +assembly. diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index 173ca54..892d808 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -74,8 +74,7 @@ runs: checked=false if ((${#groups[@]})) || [[ -z "$CONSTRAINTS_LOCK" ]]; then uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ - "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" pylock.toml "${selection[@]}" \ - --verify-pypi --project pyproject.toml + "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" pylock.toml "${selection[@]}" checked=true fi printf 'lock=pylock.toml\nchecked=%s\n' "$checked" >>"$GITHUB_OUTPUT" @@ -97,8 +96,7 @@ runs: exit 1 fi uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ - "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$VERIFY_LOCK" "${groups[@]}" \ - --verify-pypi --project pyproject.toml + "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$VERIFY_LOCK" "${groups[@]}" - id: constraints name: Export locked build requirements shell: bash @@ -111,12 +109,11 @@ runs: TARGETS: ${{ inputs.targets }} run: | set -euo pipefail - # A verified lock already had its build selection checked against PyPI with its groups. + # A verified lock already had its groups checked. if [[ -n "$CONSTRAINTS_LOCK" && "$CONSTRAINTS_LOCK" != "$VERIFY_LOCK" && ("$CHECKED" != true || "$CONSTRAINTS_LOCK" != "$BUILD_LOCK") ]]; then uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ - "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$CONSTRAINTS_LOCK" --build-constraints \ - --verify-pypi --project pyproject.toml + "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$CONSTRAINTS_LOCK" --build-constraints fi if [[ -n "$CONSTRAINTS_LOCK" ]]; then BUILD_LOCK=$CONSTRAINTS_LOCK; fi args=() diff --git a/tests/build_python_distribution/test_build.py b/tests/build_python_distribution/test_build.py index 764321f..9c07b9c 100644 --- a/tests/build_python_distribution/test_build.py +++ b/tests/build_python_distribution/test_build.py @@ -90,7 +90,7 @@ def test_build_python_distribution_rejects_backend_hash_mismatch( UV_CACHE_DIR=str(tmp_path / "uv-cache"), ) assert result.returncode != 0 - assert "release lock differs from PyPI metadata: hatchling" in result.stderr + assert "Hash mismatch" in result.stderr def test_build_python_distribution_does_not_run_hooks( diff --git a/tests/conftest.py b/tests/conftest.py index c1a8821..9d2dc32 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,23 +10,17 @@ from collections.abc import Iterator, Mapping from pathlib import Path from textwrap import dedent -from typing import Final, TypedDict, cast -from urllib.parse import urlsplit +from typing import Final import certifi import coverage import pytest import trustme import yaml -from packaging.utils import canonicalize_name from pytest_httpserver import HTTPServer from werkzeug.wrappers import Request, Response CALLER: Final = Path(__file__).parent / "fixtures/caller" -_PyPIFile = TypedDict( - "_PyPIFile", - {"filename": str, "url": str, "hashes": dict[str, str], "upload-time": str}, -) @pytest.fixture @@ -93,54 +87,6 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: return scripts -@pytest.fixture(scope="session") -def pypi_metadata(tmp_path_factory: pytest.TempPathFactory) -> Path: - listings = _lock_metadata(CALLER / "pylock.toml") - for filename, url, digest, uploaded in ( - ( - "six-1.16.0-py2.py3-none-any.whl", - "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", - "8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", - "2021-05-05T14:18:17.237677+00:00", - ), - ( - "packaging-25.0-py3-none-any.whl", - "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", - "29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", - "2025-04-19T11:48:57.875608+00:00", - ), - ( - "example-1.0-py3-none-any.whl", - "https://files.pythonhosted.org/example-1.0-py3-none-any.whl", - "0" * 64, - "2026-10-01T00:00:00+00:00", - ), - ( - "example-2.0-py3-none-any.whl", - "https://files.pythonhosted.org/example-2.0-py3-none-any.whl", - "1" * 64, - "2026-10-01T00:00:00+00:00", - ), - ): - name = filename.split("-", 1)[0] - listings.setdefault(name, []).append( - { - "filename": filename, - "url": url, - "hashes": {"sha256": digest}, - "upload-time": uploaded, - } - ) - path = tmp_path_factory.mktemp("pypi-metadata") / "listings.json" - path.write_text(json.dumps(listings)) - return path - - -@pytest.fixture(autouse=True) -def pypi_metadata_environment(pypi_metadata: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("GW_PYPI_METADATA_FILE", str(pypi_metadata)) - - @pytest.fixture(scope="session") def nab_executable(tmp_path_factory: pytest.TempPathFactory) -> Path: environment = tmp_path_factory.mktemp("nab-tools") @@ -265,14 +211,6 @@ def build_repository(source_project: Path, nab_executable: Path, monkeypatch: py "--output", "pylock.toml", ) - listings = cast("dict[str, list[_PyPIFile]]", json.loads(Path(os.environ["GW_PYPI_METADATA_FILE"]).read_text())) - for name, files in _lock_metadata(source_project / "pylock.toml").items(): - merged = {file["filename"]: file for file in listings.get(name, [])} - merged.update({file["filename"]: file for file in files}) - listings[name] = list(merged.values()) - metadata = source_project.parent / "pypi-metadata.json" - metadata.write_text(json.dumps(listings)) - monkeypatch.setenv("GW_PYPI_METADATA_FILE", str(metadata)) run(source_project, "git", "add", ".") run(source_project, "git", "commit", "-m", "Add package source") run(source_project, "git", "push", "origin", "main") @@ -477,7 +415,6 @@ def _safe_environment() -> dict[str, str]: "GH_ENTERPRISE_TOKEN", "COVERAGE_PROCESS_START", "COVERAGE_PROCESS_CONFIG", - "GW_PYPI_METADATA_FILE", ) if name in os.environ } @@ -555,21 +492,6 @@ def _expression( raise ValueError(f"unsupported GitHub step environment: {expression}") # pragma: no cover - new syntax only -def _lock_metadata(lock: Path) -> dict[str, list[_PyPIFile]]: - listings: dict[str, dict[str, _PyPIFile]] = {} - for package in tomllib.loads(lock.read_text())["packages"]: - files = listings.setdefault(canonicalize_name(package["name"]), {}) - for artifact in [*package.get("wheels", []), *([package["sdist"]] if "sdist" in package else [])]: - filename = artifact.get("name") or urlsplit(artifact["url"]).path.rsplit("/", 1)[-1] - files[filename] = { - "filename": filename, - "url": artifact["url"], - "hashes": artifact["hashes"], - "upload-time": artifact["upload-time"].isoformat(), - } - return {name: list(files.values()) for name, files in listings.items()} - - __all__ = [ "CALLER", "action", diff --git a/tests/coverage_bootstrap/sitecustomize.py b/tests/coverage_bootstrap/sitecustomize.py index b26ea5c..b131738 100644 --- a/tests/coverage_bootstrap/sitecustomize.py +++ b/tests/coverage_bootstrap/sitecustomize.py @@ -2,27 +2,6 @@ import os import sys -from pathlib import Path - -if "GW_PYPI_METADATA_FILE" in os.environ and Path(sys.argv[0]).name == "check_lock_groups.py": - import json - from io import BytesIO - from unittest.mock import patch - from urllib.parse import urlsplit - from urllib.request import Request - - _LISTINGS = json.loads(Path(os.environ["GW_PYPI_METADATA_FILE"]).read_text()) - - def _pypi_urlopen(request: Request, **_options: float) -> BytesIO: - url = urlsplit(request.full_url) - if url.scheme != "https" or url.netloc != "pypi.org" or not url.path.startswith("/simple/"): - raise ValueError(f"unexpected PyPI metadata request: {request.full_url}") - name = url.path.removeprefix("/simple/").strip("/") - return BytesIO( - json.dumps({"meta": {"api-version": "1.4"}, "name": name, "files": _LISTINGS.get(name, [])}).encode() - ) - - patch("urllib.request.urlopen", _pypi_urlopen).start() if "GW_COVERAGE_SITE_PACKAGES" in os.environ: sys.path.append(os.environ["GW_COVERAGE_SITE_PACKAGES"]) diff --git a/tests/export_build_constraints/test_export.py b/tests/export_build_constraints/test_export.py index 6c2e661..412b17d 100644 --- a/tests/export_build_constraints/test_export.py +++ b/tests/export_build_constraints/test_export.py @@ -6,7 +6,6 @@ import tomllib from collections.abc import Callable from copy import deepcopy -from datetime import UTC, datetime from pathlib import Path from textwrap import dedent from typing import cast @@ -253,55 +252,6 @@ def test_export_build_constraints_action_selects_secondary_lock(tmp_path: Path, assert output.read_text() == expected -def test_export_build_constraints_action_rejects_wrong_pypi_digest(tmp_path: Path, action_scripts: Path) -> None: - lock = tmp_path / "pylock.build.toml" - write_published_matrix_lock(lock) - document = tomlkit.parse(lock.read_text()) - document["packages"][0]["wheels"][0]["hashes"]["sha256"] = "0" * 64 - lock.write_text(tomlkit.dumps(document)) - - result = action( - tmp_path, - tmp_path, - action_scripts, - "export-build-constraints", - version="", - LOCK="pylock.build.toml", - TARGET_PLATFORM="macos_arm64", - TARGETS="3.15.0:cpython", - ) - - assert result.returncode != 0 - assert "release lock differs from PyPI metadata: packaging:" in result.stderr - - -@pytest.mark.parametrize("groups", ["", "release-check"], ids=["export-only", "with-installed-group"]) -def test_export_build_constraints_rejects_uncooled_secondary_lock( - tmp_path: Path, action_scripts: Path, groups: str -) -> None: - lock = tmp_path / "pylock.build.toml" - write_published_matrix_lock(lock) - document = tomlkit.parse(lock.read_text()) - document["packages"][0]["wheels"][0]["upload-time"] = datetime(2099, 1, 1, tzinfo=UTC) - lock.write_text(tomlkit.dumps(document)) - if groups: - (tmp_path / "pylock.toml").write_bytes((CALLER / "pylock.toml").read_bytes()) - (tmp_path / "pyproject.toml").write_bytes((CALLER / "pyproject.toml").read_bytes()) - result = action( - tmp_path, - tmp_path, - action_scripts, - "install-locked-tools" if groups else "export-build-constraints", - version="", - LOCK="pylock.build.toml", - CONSTRAINTS_LOCK="pylock.build.toml", - GROUPS=groups, - VENV=str(tmp_path / "tools"), - ) - assert result.returncode != 0 - assert "uncooled artifact" in result.stderr - - def test_export_build_constraints_rejects_different_target_versions(tmp_path: Path, action_scripts: Path) -> None: lock = tmp_path / "pylock.build.toml" write_published_matrix_lock(lock) diff --git a/tests/fixtures/caller/pyproject.toml b/tests/fixtures/caller/pyproject.toml index 344646b..479adbd 100644 --- a/tests/fixtures/caller/pyproject.toml +++ b/tests/fixtures/caller/pyproject.toml @@ -85,9 +85,3 @@ type = [ { directory = "removal", name = "Removed", showcontent = true }, { directory = "packaging", name = "Packaging", showcontent = true }, ] - -[tool.nab.packages.release-creator] -uploaded-prior-to = 2026-10-25T07:08:32Z - -[tool.nab.packages.uv] -uploaded-prior-to = 2026-10-22T01:32:34Z diff --git a/tests/install_locked_tools/test_groups.py b/tests/install_locked_tools/test_groups.py new file mode 100644 index 0000000..c764971 --- /dev/null +++ b/tests/install_locked_tools/test_groups.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from typing import Final + +import pytest + +_TOOL: Final = Path(__file__).resolve().parents[2] / "tools/check_lock_groups.py" + + +@pytest.fixture +def lock(tmp_path: Path) -> Path: + path = tmp_path / "pylock.toml" + path.write_text( + 'lock-version = "1.0"\nrequires-python = ">=3.15"\ndependency-groups = ["release-check"]\n' + 'created-by = "test"\npackages = []\n' + ) + return path + + +def _check(*arguments: str) -> subprocess.CompletedProcess[str]: + return subprocess.run([sys.executable, str(_TOOL), *arguments], text=True, capture_output=True, check=False) + + +@pytest.mark.parametrize( + ("arguments", "accepted"), + [ + pytest.param(("release-check",), True, id="present-group"), + pytest.param(("--build-constraints",), True, id="lock-without-build-group"), + pytest.param(("release-news",), False, id="missing-group"), + ], +) +def test_lock_groups_selects_present_groups(lock: Path, arguments: tuple[str, ...], accepted: bool) -> None: + result = _check(str(lock), *arguments) + + assert (result.returncode == 0) is accepted, result.stderr + assert ("missing lock dependency groups: release-news" in result.stderr) is not accepted + + +def test_lock_groups_requires_a_group(lock: Path) -> None: + result = _check(str(lock)) + + assert result.returncode != 0 + assert "at least one dependency group is required" in result.stderr + + +@pytest.mark.parametrize("location", ["lock", "request"]) +def test_lock_groups_rejects_unnormalized_group(lock: Path, location: str) -> None: + if location == "lock": + lock.write_text(lock.read_text().replace('["release-check"]', '["release-check", "project_runtime"]')) + + result = _check(str(lock), "release-check" if location == "lock" else "release_check") + + assert result.returncode != 0 + assert "dependency group names must be normalized" in result.stderr + + +def test_lock_groups_reports_unreadable_lock(tmp_path: Path) -> None: + result = _check(str(tmp_path / "missing.toml"), "release-check") + + assert result.returncode != 0 + assert "No such file or directory" in result.stderr + + +def test_lock_groups_import_has_no_cli_side_effect() -> None: + result = subprocess.run( + [sys.executable, "-c", f"import runpy; runpy.run_path({str(_TOOL)!r}, run_name='imported')"], + text=True, + capture_output=True, + check=False, + ) + + assert (result.returncode, result.stdout, result.stderr) == (0, "", "") diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index 3e67d60..e0c3306 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -5,7 +5,6 @@ import subprocess import sys import tomllib -from datetime import UTC, datetime from pathlib import Path import pytest @@ -163,212 +162,6 @@ def test_install_locked_tools_rejects_missing_group(tmp_path: Path, action_scrip assert "missing lock dependency groups: relesae" in result.stderr -@pytest.mark.parametrize( - ("kind", "message"), - [ - pytest.param( - "future-creation", - "created in the future", - id="future-creation", - ), - pytest.param( - "recent-artifact", - "uncooled artifact", - id="recent-artifact", - ), - ], -) -def test_install_locked_tools_rejects_uncooled_lock(tmp_path: Path, kind: str, message: str) -> None: - source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((CALLER / "pylock.toml").read_text()) - if kind == "future-creation": - document["tool"]["release-creator"]["created-at"] = datetime(2099, 10, 23, tzinfo=UTC) - else: - package = next(package for package in document["packages"] if package["name"] == "cython") - package["wheels"][0]["upload-time"] = datetime(2099, 10, 23, tzinfo=UTC) - (tmp_path / "pylock.toml").write_text(tomlkit.dumps(document)) - - result = subprocess.run( - ["python", str(source / "tools/check_lock_groups.py"), str(tmp_path / "pylock.toml"), "release-check"], - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode != 0 - assert message in result.stderr - - -def test_install_locked_tools_rejects_unknown_lock_producer(tmp_path: Path) -> None: - lock = tmp_path / "pylock.toml" - lock.write_text( - 'lock-version = "1.0"\nrequires-python = ">=3.15"\ncreated-by = "test"\n' - 'dependency-groups = ["release-check"]\npackages = []\n' - ) - - result = subprocess.run( - ["python", str(Path(__file__).resolve().parents[2] / "tools/check_lock_groups.py"), str(lock), "release-check"], - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode != 0 - assert "lock has no recognized producer" in result.stderr - - -def test_install_locked_tools_checks_build_group_with_selected_groups(tmp_path: Path) -> None: - source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((CALLER / "pylock.toml").read_text()) - package = next(package for package in document["packages"] if package["name"] == "cython") - package["wheels"][0]["upload-time"] = datetime(2099, 1, 1, tzinfo=UTC) - lock = tmp_path / "pylock.toml" - lock.write_text(tomlkit.dumps(document)) - - result = subprocess.run( - [ - "python", - str(source / "tools/check_lock_groups.py"), - str(lock), - "release-news", - "--project", - str(CALLER / "pyproject.toml"), - ], - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode != 0 - assert "uncooled artifact" in result.stderr - - -@pytest.mark.parametrize("change", ["missing", "naive"], ids=["missing", "naive"]) -def test_install_locked_tools_rejects_undated_artifact(tmp_path: Path, change: str) -> None: - source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((CALLER / "pylock.toml").read_text()) - package = next(package for package in document["packages"] if package["name"] == "cython") - if change == "missing": - del package["wheels"][0]["upload-time"] - else: - package["wheels"][0]["upload-time"] = datetime(2026, 10, 1) - lock = tmp_path / "pylock.toml" - lock.write_text(tomlkit.dumps(document)) - - result = subprocess.run( - [ - "python", - str(source / "tools/check_lock_groups.py"), - str(lock), - "release-news", - "--project", - str(CALLER / "pyproject.toml"), - ], - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode != 0 - assert "uncooled artifact" in result.stderr - - -def test_install_locked_tools_skips_unselected_package(tmp_path: Path) -> None: - source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((CALLER / "pylock.toml").read_text()) - package = next(package for package in document["packages"] if package["name"] == "release-creator") - package["wheels"][0]["upload-time"] = datetime(2099, 1, 1, tzinfo=UTC) - lock = tmp_path / "pylock.toml" - lock.write_text(tomlkit.dumps(document)) - - result = subprocess.run( - ["python", str(source / "tools/check_lock_groups.py"), str(lock), "release-check"], - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode == 0, result.stderr - - -@pytest.mark.parametrize( - ("group", "accepted"), - [pytest.param("release-check", True, id="release"), pytest.param("test", False, id="test")], -) -def test_install_locked_tools_uses_full_lock_provenance_and_selected_groups( - tmp_path: Path, group: str, accepted: bool -) -> None: - source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((CALLER / "pylock.toml").read_text()) - document["dependency-groups"].append("test") - package = next(package for package in document["packages"] if package["name"] == "release-creator") - package["marker"] = '"test" in dependency_groups' - package["wheels"][0]["url"] = package["wheels"][0]["url"].replace("files.pythonhosted.org", "example.org") - for artifact in [package["sdist"], *package["wheels"]]: - artifact["upload-time"] = datetime(2026, 10, 1, tzinfo=UTC) - lock = tmp_path / "pylock.toml" - lock.write_text(tomlkit.dumps(document)) - - result = subprocess.run( - ["python", str(source / "tools/check_lock_groups.py"), str(lock), group], - text=True, - capture_output=True, - check=False, - ) - - assert (result.returncode == 0) is accepted, result.stderr - if not accepted: - assert "non-PyPI artifact" in result.stderr - - -@pytest.mark.parametrize( - "source_kind", ["foreign-host", "insecure-url", "local-path"], ids=["foreign-host", "insecure-url", "local-path"] -) -def test_install_locked_tools_rejects_non_pypi_artifact(tmp_path: Path, source_kind: str) -> None: - source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((CALLER / "pylock.toml").read_text()) - package = next(package for package in document["packages"] if package["name"] == "cython") - artifact = package["wheels"][0] - if source_kind == "foreign-host": - artifact["url"] = artifact["url"].replace("files.pythonhosted.org", "example.org") - elif source_kind == "insecure-url": - artifact["url"] = artifact["url"].replace("https://", "http://") - else: - artifact["path"] = artifact.pop("url").rsplit("/", 1)[-1] - lock = tmp_path / "pylock.toml" - lock.write_text(tomlkit.dumps(document)) - - result = subprocess.run( - ["python", str(source / "tools/check_lock_groups.py"), str(lock), "release-news"], - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode != 0 - assert "non-PyPI artifact" in result.stderr - - -def test_install_locked_tools_checks_exact_group_marker(tmp_path: Path) -> None: - source = Path(__file__).resolve().parents[2] - document = tomlkit.parse((CALLER / "pylock.toml").read_text()) - package = next(package for package in document["packages"] if package["name"] == "cython") - package["marker"] = '"release-check" in dependency_groups and "build" not in dependency_groups' - package["wheels"][0]["url"] = package["wheels"][0]["url"].replace("files.pythonhosted.org", "example.org") - lock = tmp_path / "pylock.toml" - lock.write_text(tomlkit.dumps(document)) - - result = subprocess.run( - ["python", str(source / "tools/check_lock_groups.py"), str(lock), "release-check"], - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode != 0 - assert "non-PyPI artifact" in result.stderr - - def test_install_locked_tools_rejects_artifact_version_mismatch(tmp_path: Path) -> None: source = Path(__file__).resolve().parents[2] document = tomlkit.parse((CALLER / "pylock.toml").read_text()) @@ -389,93 +182,6 @@ def test_install_locked_tools_rejects_artifact_version_mismatch(tmp_path: Path) assert "package version" in result.stderr -@pytest.mark.parametrize( - ("policy", "accepted"), - [ - pytest.param("", False, id="no-override"), - pytest.param( - "[tool.nab.packages.release-creator]\nuploaded-prior-to = 2026-10-25T07:08:32Z\n", True, id="package" - ), - pytest.param( - '[tool.nab.packages.release-creator]\nuploaded-prior-to = "2026-10-25T07:08:32+00:00"\n', True, id="text" - ), - pytest.param( - '[[tool.nab.package-rules]]\nmatch = ["release_creator"]\nuploaded-prior-to = 2026-10-25T07:08:32Z\n', - True, - id="rule", - ), - pytest.param('[tool.nab.packages.release-creator]\nuploaded-prior-to = "P0D"\n', True, id="duration"), - pytest.param("[tool.nab.packages.release-creator]\nuploaded-prior-to = false\n", True, id="disabled"), - pytest.param( - "[tool.nab.packages.release-creator]\nuploaded-prior-to = 2026-10-25T07:08:30Z\n", False, id="too-early" - ), - pytest.param('[tool.nab.packages.release-creator]\nuploaded-prior-to = "P3D"\n', False, id="long-duration"), - pytest.param("[tool.nab.packages.towncrier]\nuploaded-prior-to = false\n", False, id="other-package"), - ], -) -def test_install_locked_tools_honours_caller_package_cutoff(tmp_path: Path, policy: str, accepted: bool) -> None: - source = Path(__file__).resolve().parents[2] - project = tmp_path / "pyproject.toml" - project.write_text(f"{policy}[tool.nab.packages.uv]\nuploaded-prior-to = 2026-10-22T01:32:34Z\n") - - result = subprocess.run( - [ - "python", - str(source / "tools/check_lock_groups.py"), - str(CALLER / "pylock.toml"), - "release-news", - "--project", - str(project), - ], - text=True, - capture_output=True, - check=False, - ) - - assert (result.returncode == 0) is accepted, result.stderr - if not accepted: - assert "uncooled artifact" in result.stderr - - -@pytest.mark.parametrize( - "value", - [pytest.param("2026-10-25T07:08:32", id="naive"), pytest.param("soon", id="text"), pytest.param("7", id="number")], -) -def test_install_locked_tools_rejects_invalid_caller_cutoff(tmp_path: Path, value: str) -> None: - source = Path(__file__).resolve().parents[2] - project = tmp_path / "pyproject.toml" - project.write_text( - "[tool.nab.packages.release-creator]\nuploaded-prior-to = " + (value if value == "7" else f'"{value}"') + "\n" - ) - - result = subprocess.run( - [ - "python", - str(source / "tools/check_lock_groups.py"), - str(CALLER / "pylock.toml"), - "release-news", - "--project", - str(project), - ], - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode != 0 - assert "invalid Nab cutoff for release-creator" in result.stderr - - -def test_install_locked_tools_constraints_only_checks_lock_age(tmp_path: Path, action_scripts: Path) -> None: - (tmp_path / "pyproject.toml").write_bytes((CALLER / "pyproject.toml").read_bytes()) - document = tomlkit.parse((CALLER / "pylock.toml").read_text()) - document["tool"]["release-creator"]["created-at"] = datetime(2099, 10, 23, tzinfo=UTC) - (tmp_path / "pylock.toml").write_text(tomlkit.dumps(document)) - result = action(tmp_path, tmp_path, action_scripts, "install-locked-tools", version="") - assert result.returncode != 0 - assert "created in the future" in result.stderr - - def test_install_locked_tools_constraints_only_accepts_no_build_group(tmp_path: Path, action_scripts: Path) -> None: (tmp_path / "pyproject.toml").write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n') (tmp_path / "pylock.toml").write_text( diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py deleted file mode 100644 index 7e9e035..0000000 --- a/tests/install_locked_tools/test_metadata.py +++ /dev/null @@ -1,586 +0,0 @@ -from __future__ import annotations - -import json -import runpy -import ssl -import sys -from email.message import Message -from io import BytesIO -from pathlib import Path -from textwrap import dedent -from typing import NotRequired, TypedDict -from urllib.error import HTTPError, URLError - -import certifi -import pytest -from pytest_mock import MockerFixture - -_PyPIFile = TypedDict( - "_PyPIFile", - { - "filename": str, - "url": str, - "upload-time": NotRequired[str], - "hashes": dict[str, str], - "yanked": NotRequired[bool], - }, -) - - -class _PyPIListing(TypedDict): - meta: dict[str, str] - name: str - files: list[_PyPIFile] - - -@pytest.fixture -def lock_case(tmp_path: Path) -> tuple[Path, Path]: - lock = tmp_path / "pylock.toml" - lock.write_text( - dedent( - """\ - lock-version = "1.0" - requires-python = ">=3.15" - dependency-groups = ["release-check"] - created-by = "test" - [tool.nab] - created-at = 2026-10-20T00:00:00Z - [[packages]] - name = "six" - version = "1.16.0" - marker = '"release-check" in dependency_groups' - index = "https://pypi.org/simple/" - [[packages.wheels]] - name = "six-1.16.0-py2.py3-none-any.whl" - url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl" - upload-time = 2021-05-05T14:18:17.237677Z - hashes.sha256 = "8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" - """ - ) - ) - project = tmp_path / "pyproject.toml" - project.write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n') - return lock, project - - -@pytest.fixture -def pypi_listing() -> _PyPIListing: - return { - "meta": {"api-version": "1.4"}, - "name": "six", - "files": [ - { - "filename": "six-1.16.0-py2.py3-none-any.whl", - "url": "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", - "upload-time": "2021-05-05T14:18:17.237677Z", - "hashes": {"sha256": "8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - } - ], - } - - -def test_lock_metadata_matches_pypi( - lock_case: tuple[Path, Path], pypi_listing: _PyPIListing, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch -) -> None: - request = mocker.patch( - "urllib.request.urlopen", autospec=True, return_value=BytesIO(json.dumps(pypi_listing).encode()) - ) - context = mocker.patch.object(ssl, "create_default_context", autospec=True) - - _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) - - assert request.call_args.args[0].full_url == "https://pypi.org/simple/six/" - assert context.call_args.kwargs == {"cafile": certifi.where()} - - -@pytest.mark.parametrize( - ("producer", "uploaded", "accepted"), - [ - pytest.param("release-creator (uv)", "2021-05-05T14:18:17Z", True, id="uv-whole-second"), - pytest.param("release-creator (uv)", "2021-05-05T14:18:16Z", False, id="uv-earlier-second"), - pytest.param("test", "2021-05-05T14:18:17Z", False, id="nab-needs-fraction"), - ], -) -def test_lock_metadata_checks_producer_timestamp_precision( - lock_case: tuple[Path, Path], - pypi_listing: _PyPIListing, - mocker: MockerFixture, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], - producer: str, - uploaded: str, - accepted: bool, -) -> None: - lock = lock_case[0] - text = lock.read_text().replace('created-by = "test"', f'created-by = "{producer}"') - if producer == "release-creator (uv)": - text = text.replace("[tool.nab]", "[tool.release-creator]") - lock.write_text(text.replace("2021-05-05T14:18:17.237677Z", uploaded)) - request = mocker.patch( - "urllib.request.urlopen", side_effect=lambda *_args, **_kwargs: BytesIO(json.dumps(pypi_listing).encode()) - ) - mocker.patch("time.sleep") - - if accepted: - _run_check(lock_case, monkeypatch, "--verify-pypi") - assert request.call_count == 1 - else: - with pytest.raises(SystemExit, match="2"): - _run_check(lock_case, monkeypatch, "--verify-pypi") - assert "release lock differs from PyPI metadata" in capsys.readouterr().err - - -def test_lock_metadata_rejects_yanked_artifact( - lock_case: tuple[Path, Path], - pypi_listing: _PyPIListing, - mocker: MockerFixture, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - pypi_listing["files"][0]["yanked"] = True - mocker.patch( - "urllib.request.urlopen", side_effect=lambda *_args, **_kwargs: BytesIO(json.dumps(pypi_listing).encode()) - ) - mocker.patch("time.sleep") - - with pytest.raises(SystemExit, match="2"): - _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) - - assert "release lock differs from PyPI metadata" in capsys.readouterr().err - - -def test_lock_metadata_retries_transient_network_error( - lock_case: tuple[Path, Path], pypi_listing: _PyPIListing, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch -) -> None: - request = mocker.patch( - "urllib.request.urlopen", - side_effect=[URLError("handshake timed out"), BytesIO(json.dumps(pypi_listing).encode())], - ) - sleep = mocker.patch("time.sleep") - - _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) - - assert request.call_count == 2 - sleep.assert_called_once_with(1) - - -def test_lock_metadata_retries_stale_listing( - lock_case: tuple[Path, Path], pypi_listing: _PyPIListing, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch -) -> None: - stale = {**pypi_listing, "files": []} - request = mocker.patch( - "urllib.request.urlopen", - side_effect=[BytesIO(json.dumps(stale).encode()), BytesIO(json.dumps(pypi_listing).encode())], - ) - sleep = mocker.patch("time.sleep") - - _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) - - assert request.call_count == 2 - sleep.assert_called_once_with(1) - - -def test_lock_metadata_stops_after_five_network_errors( - lock_case: tuple[Path, Path], - mocker: MockerFixture, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - request = mocker.patch("urllib.request.urlopen", autospec=True, side_effect=URLError("handshake timed out")) - mocker.patch("time.sleep") - - with pytest.raises(SystemExit, match="2"): - _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) - - assert request.call_count == 5 - assert "handshake timed out" in capsys.readouterr().err - - -@pytest.mark.parametrize( - "status", - [pytest.param(404, id="not-yet-listed"), pytest.param(429, id="rate-limit"), pytest.param(503, id="server-error")], -) -def test_lock_metadata_retries_transient_http_error( - lock_case: tuple[Path, Path], - pypi_listing: _PyPIListing, - mocker: MockerFixture, - monkeypatch: pytest.MonkeyPatch, - status: int, -) -> None: - request = mocker.patch( - "urllib.request.urlopen", - side_effect=[ - HTTPError("https://pypi.org/simple/six/", status, "transient", Message(), None), - BytesIO(json.dumps(pypi_listing).encode()), - ], - ) - mocker.patch("time.sleep") - - _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) - - assert request.call_count == 2 - - -@pytest.mark.parametrize( - ("status", "calls"), - [pytest.param(404, 5, id="not-found"), pytest.param(503, 5, id="repeated-server-error")], -) -def test_lock_metadata_rejects_http_error( - lock_case: tuple[Path, Path], - mocker: MockerFixture, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], - status: int, - calls: int, -) -> None: - request = mocker.patch( - "urllib.request.urlopen", - side_effect=HTTPError("https://pypi.org/simple/six/", status, "unavailable", Message(), None), - ) - mocker.patch("time.sleep") - - with pytest.raises(SystemExit, match="2"): - _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) - - assert request.call_count == calls - assert f"HTTP Error {status}" in capsys.readouterr().err - - -def test_lock_metadata_requires_group( - lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - source = Path(__file__).resolve().parents[2] - monkeypatch.syspath_prepend(str(source / "tools")) - monkeypatch.setattr(sys, "argv", ["check_lock_groups.py", str(lock_case[0])]) - command = runpy.run_path(str(source / "tools/check_lock_groups.py"), run_name="lock_check") - - with pytest.raises(SystemExit, match="2"): - command["main"]() - - assert "at least one dependency group is required" in capsys.readouterr().err - - -def test_lock_metadata_checks_base_selection_without_groups( - lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - lock = lock_case[0] - lock.write_text( - lock.read_text() - .replace('dependency-groups = ["release-check"]\n', "") - .replace('"release-check" in dependency_groups', '"build" not in dependency_groups') - .replace("files.pythonhosted.org", "example.org") - ) - - with pytest.raises(SystemExit, match="2"): - _run_check(lock_case, monkeypatch, "--build-constraints") - - assert "non-PyPI artifact" in capsys.readouterr().err - - -@pytest.mark.parametrize( - "change", [pytest.param("api-version", id="api-version"), pytest.param("project-name", id="project-name")] -) -def test_lock_metadata_rejects_invalid_listing( - lock_case: tuple[Path, Path], - pypi_listing: _PyPIListing, - mocker: MockerFixture, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], - change: str, -) -> None: - if change == "api-version": - pypi_listing["meta"]["api-version"] = "2.0" - else: - pypi_listing["name"] = "other" - mocker.patch("urllib.request.urlopen", autospec=True, return_value=BytesIO(json.dumps(pypi_listing).encode())) - - with pytest.raises(SystemExit, match="2"): - _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) - - assert "invalid PyPI listing for six" in capsys.readouterr().err - - -@pytest.mark.parametrize( - "listing", - [ - pytest.param([], id="non-object-root"), - pytest.param({"meta": [], "files": []}, id="non-object-metadata"), - pytest.param({"meta": {"api-version": "1.4"}, "name": "six", "files": ["bad"]}, id="non-object-file"), - ], -) -def test_lock_metadata_rejects_non_object_listing( - lock_case: tuple[Path, Path], - mocker: MockerFixture, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], - listing: object, -) -> None: - mocker.patch("urllib.request.urlopen", autospec=True, return_value=BytesIO(json.dumps(listing).encode())) - - with pytest.raises(SystemExit, match="2"): - _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) - - assert "invalid PyPI listing for six" in capsys.readouterr().err - - -@pytest.mark.parametrize("path", [".", "../other"], ids=["checkout", "other-directory"]) -def test_lock_metadata_rejects_local_release_creator( - lock_case: tuple[Path, Path], - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], - path: str, -) -> None: - lock = lock_case[0] - lock.write_text( - lock.read_text().split("[[packages]]", 1)[0] - + '[[packages]]\nname = "release-creator"\nmarker = \'"release-check" in dependency_groups\'\n' - + f'directory = {{ path = "{path}" }}\n' - ) - - with pytest.raises(SystemExit, match="2"): - _run_check(lock_case, monkeypatch) - - assert "direct-source package: release-creator" in capsys.readouterr().err - - -@pytest.mark.parametrize( - ("source", "message"), - [ - pytest.param( - 'archive = { url = "https://evil.invalid/six.whl", hashes = { sha256 = "' + "0" * 64 + '" } }', - "direct-source", - id="archive", - ), - pytest.param('directory = { path = "." }', "direct-source", id="directory"), - pytest.param( - 'vcs = { type = "git", url = "https://evil.invalid/six.git", commit-id = "' + "a" * 40 + '" }', - "direct-source", - id="vcs", - ), - ], -) -def test_lock_metadata_rejects_unchecked_source( - lock_case: tuple[Path, Path], - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], - source: str, - message: str, -) -> None: - lock = lock_case[0] - lock.write_text(lock.read_text().split('index = "https://pypi.org/simple/"', 1)[0] + source + "\n") - - with pytest.raises(SystemExit, match="2"): - _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) - - assert message in capsys.readouterr().err - - -def test_lock_metadata_rejects_indexless_wheel( - lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - lock = lock_case[0] - lock.write_text(lock.read_text().replace('index = "https://pypi.org/simple/"\n', "")) - - with pytest.raises(SystemExit, match="2"): - _run_check(lock_case, monkeypatch, "--project", str(lock_case[1])) - - assert "uncooled non-index package" in capsys.readouterr().err - - -def test_lock_metadata_checks_other_locked_platform( - lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - lock = lock_case[0] - lock.write_text( - lock.read_text() - .replace( - 'dependency-groups = ["release-check"]', - 'dependency-groups = ["release-check"]\nenvironments = [\'sys_platform == "win32"\']', - ) - .replace( - "marker = '\"release-check\" in dependency_groups'", - 'marker = \'"release-check" in dependency_groups and sys_platform == "win32"\'', - ) - .replace("files.pythonhosted.org", "evil.invalid") - ) - - with pytest.raises(SystemExit, match="2"): - _run_check(lock_case, monkeypatch, "--project", str(lock_case[1])) - - assert "non-PyPI artifact" in capsys.readouterr().err - - -def test_lock_metadata_checks_nested_group_marker( - lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - lock = lock_case[0] - lock.write_text( - lock.read_text() - .replace( - "marker = '\"release-check\" in dependency_groups'", - 'marker = \'("release-check" in dependency_groups and sys_platform == "win32") or "test" in dependency_groups\'', - ) - .replace("files.pythonhosted.org", "evil.invalid") - ) - - with pytest.raises(SystemExit, match="2"): - _run_check(lock_case, monkeypatch, "--project", str(lock_case[1])) - - assert "non-PyPI artifact" in capsys.readouterr().err - - -def test_lock_metadata_checks_package_excluding_extras( - lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - lock = lock_case[0] - lock.write_text( - lock.read_text() - .replace("marker = '\"release-check\" in dependency_groups'", "marker = '\"web\" not in extras'") - .replace("files.pythonhosted.org", "evil.invalid") - ) - - with pytest.raises(SystemExit, match="2"): - _run_check(lock_case, monkeypatch, "--project", str(lock_case[1])) - - assert "non-PyPI artifact" in capsys.readouterr().err - - -def test_lock_metadata_checks_each_duplicate_artifact( - lock_case: tuple[Path, Path], - pypi_listing: _PyPIListing, - mocker: MockerFixture, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - lock = lock_case[0] - head, package = lock.read_text().split("[[packages]]") - lock.write_text( - f"{head}[[packages]]{package.replace(pypi_listing['files'][0]['hashes']['sha256'], '0' * 64)}" - f"[[packages]]{package}" - ) - mocker.patch( - "urllib.request.urlopen", side_effect=lambda *_args, **_kwargs: BytesIO(json.dumps(pypi_listing).encode()) - ) - mocker.patch("time.sleep") - - with pytest.raises(SystemExit, match="2"): - _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) - - assert "release lock differs from PyPI metadata" in capsys.readouterr().err - - -@pytest.mark.parametrize( - "change", - ["missing-file", "missing-hash", "wrong-hash", "missing-date", "forged-date", "wrong-url"], -) -def test_lock_metadata_rejects_pypi_mismatch( - lock_case: tuple[Path, Path], - pypi_listing: _PyPIListing, - mocker: MockerFixture, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], - change: str, -) -> None: - file = pypi_listing["files"][0] - if change == "missing-file": - pypi_listing["files"] = [] - elif change == "missing-hash": - file["hashes"] = {} - elif change == "wrong-hash": - file["hashes"]["sha256"] = "0" * 64 - elif change == "missing-date": - del file["upload-time"] - elif change == "forged-date": - lock = lock_case[0] - lock.write_text(lock.read_text().replace("2021-05-05T14:18:17.237677Z", "2020-05-05T14:18:17.237677Z")) - else: - file["url"] = "https://files.pythonhosted.org/other.whl" - mocker.patch( - "urllib.request.urlopen", side_effect=lambda *_args, **_kwargs: BytesIO(json.dumps(pypi_listing).encode()) - ) - mocker.patch("time.sleep") - - with pytest.raises(SystemExit, match="2"): - _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) - - assert "release lock differs from PyPI metadata" in capsys.readouterr().err - - -@pytest.mark.parametrize( - "change", ["lock-index", "project-index", "package-index", "rule-index", "index-settings", "nab-toml"] -) -def test_lock_metadata_rejects_unapproved_indexes( - lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], change: str -) -> None: - lock, project = lock_case - if change == "lock-index": - lock.write_text( - lock.read_text().replace('index = "https://pypi.org/simple/"', 'index = "https://example.org/"') - ) - elif change == "project-index": - project.write_text(project.read_text() + '[[tool.nab.indexes]]\nname = "other"\nurl = "https://example.org/"\n') - elif change == "package-index": - project.write_text(project.read_text() + '[tool.nab.packages.six]\nindex = "other"\n') - elif change == "rule-index": - project.write_text(project.read_text() + '[[tool.nab.package-rules]]\nmatch = ["six"]\nindex = "other"\n') - elif change == "index-settings": - project.write_text(project.read_text() + "[tool.nab.index.pypi]\nallow-insecure-host = true\n") - else: - (project.parent / "nab.toml").write_text("[tool.nab]\nuploaded-prior-to = false\n") - - with pytest.raises(SystemExit, match="2"): - _run_check(lock_case, monkeypatch, "--project", str(project)) - - assert "index" in capsys.readouterr().err - - -@pytest.mark.parametrize( - ("change", "group"), - [ - pytest.param("lock", "release-check", id="lock-group"), - pytest.param("argument", "Release-Check", id="selected-group"), - ], -) -def test_lock_metadata_rejects_unnormalized_group( - lock_case: tuple[Path, Path], - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], - change: str, - group: str, -) -> None: - lock = lock_case[0] - if change == "lock": - lock.write_text( - lock.read_text().replace( - 'dependency-groups = ["release-check"]', 'dependency-groups = ["release-check", "project_runtime"]' - ) - ) - source = Path(__file__).resolve().parents[2] - monkeypatch.syspath_prepend(str(source / "tools")) - monkeypatch.setattr(sys, "argv", ["check_lock_groups.py", str(lock), group]) - - with pytest.raises(SystemExit, match="2"): - runpy.run_path(str(source / "tools/check_lock_groups.py"), run_name="__main__") - - assert "dependency group names must be normalized" in capsys.readouterr().err - - -def test_lock_metadata_rejects_non_table_producer( - lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - lock = lock_case[0] - lock.write_text( - lock.read_text().replace("[tool.nab]\ncreated-at = 2026-10-20T00:00:00Z\n", '[tool]\nnab = "oops"\n') - ) - - with pytest.raises(SystemExit, match="2"): - _run_check(lock_case, monkeypatch) - - assert "lock producer settings must be a table" in capsys.readouterr().err - - -def _run_check(lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, *options: str) -> None: - source = Path(__file__).resolve().parents[2] - monkeypatch.syspath_prepend(str(source / "tools")) - monkeypatch.setattr(sys, "argv", ["check_lock_groups.py", str(lock_case[0]), "release-check", *options]) - runpy.run_path(str(source / "tools/check_lock_groups.py"), run_name="__main__") diff --git a/tests/install_locked_tools/test_secondary_lock.py b/tests/install_locked_tools/test_secondary_lock.py index 0e9d02f..d94494c 100644 --- a/tests/install_locked_tools/test_secondary_lock.py +++ b/tests/install_locked_tools/test_secondary_lock.py @@ -1,9 +1,7 @@ from __future__ import annotations from pathlib import Path -from textwrap import dedent -import pytest from conftest import action @@ -28,49 +26,6 @@ def test_verify_secondary_lock_accepts_cooled_selection(tmp_path: Path, caller: assert result.returncode == 0, result.stderr -@pytest.mark.parametrize( - ("action_name", "group"), - [ - pytest.param("install-locked-tools", "project-runtime", id="zipapp-runtime"), - pytest.param("export-build-constraints", "pgo", id="profile-build"), - ], -) -def test_verify_secondary_lock_rejects_direct_source( - tmp_path: Path, caller: Path, action_scripts: Path, action_name: str, group: str -) -> None: - lock = tmp_path / "pylock.zipapp.toml" - lock.write_text( - dedent( - f"""\ - lock-version = "1.0" - requires-python = ">=3.15" - dependency-groups = ["build", "{group}"] - created-by = "test" - [tool.nab] - created-at = 2026-10-19T00:00:00Z - [[packages]] - name = "six" - version = "1.16.0" - marker = '"{group}" in dependency_groups' - archive = {{ url = "https://evil.invalid/six.whl", hashes = {{ sha256 = "0000000000000000000000000000000000000000000000000000000000000000" }} }} - """ - ) - ) - - result = action( - caller, - tmp_path, - action_scripts, - action_name, - version="", - VERIFY_GROUPS=group, - **({"VERIFY_LOCK": str(lock)} if group == "project-runtime" else {"LOCK": str(lock)}), - ) - - assert result.returncode != 0 - assert "direct-source package: six" in result.stderr - - def test_verify_secondary_lock_requires_group(tmp_path: Path, caller: Path, action_scripts: Path) -> None: result = action( caller, @@ -83,32 +38,3 @@ def test_verify_secondary_lock_requires_group(tmp_path: Path, caller: Path, acti assert result.returncode != 0 assert "verify-lock and verify-groups must be set together" in result.stderr - - -def test_verify_secondary_lock_rejects_forged_pypi_hash( - tmp_path: Path, caller: Path, action_scripts: Path, pypi_metadata_environment: None -) -> None: - lock = tmp_path / "pylock.zipapp.toml" - lock.write_text( - 'lock-version = "1.0"\nrequires-python = ">=3.15"\n' - 'dependency-groups = ["project-runtime"]\ncreated-by = "test"\n' - "[tool.nab]\ncreated-at = 2026-10-19T00:00:00Z\n" - '[[packages]]\nname = "six"\nversion = "1.16.0"\n' - 'marker = "\'project-runtime\' in dependency_groups"\nindex = "https://pypi.org/simple/"\n' - '[[packages.wheels]]\nname = "six-1.16.0-py2.py3-none-any.whl"\n' - 'url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl"\n' - 'upload-time = 2021-05-05T14:18:17.237677Z\nhashes.sha256 = "' + "0" * 64 + '"\n' - ) - - result = action( - caller, - tmp_path, - action_scripts, - "install-locked-tools", - version="", - VERIFY_LOCK=str(lock), - VERIFY_GROUPS="project-runtime", - ) - - assert result.returncode != 0 - assert "release lock differs from PyPI metadata" in result.stderr diff --git a/tests/install_locked_tools/test_workspace_sources.py b/tests/install_locked_tools/test_workspace_sources.py deleted file mode 100644 index b2044d4..0000000 --- a/tests/install_locked_tools/test_workspace_sources.py +++ /dev/null @@ -1,92 +0,0 @@ -from __future__ import annotations - -import subprocess -import sys -from pathlib import Path -from typing import Final - -import pytest - -from tools.check_lock_groups import validate_workspace_sources - -_TOOLS: Final = Path(__file__).resolve().parents[2] / "tools" - - -def test_workspace_sources_accept_declared_member(tmp_path: Path) -> None: - (tmp_path / "pyproject.toml").write_text( - '[project]\nname = "bare"\n[tool.uv.workspace]\nmembers = ["packages/*"]\nexclude = ["packages/unused"]\n' - ) - for name in ("meta", "other", "unused"): - member = tmp_path / "packages" / name - member.mkdir(parents=True) - (member / "pyproject.toml").write_text( - f'[project]\nname = "{name}"\n' - + ("[tool.uv.sources]\nbare = { workspace = true }\n" if name == "meta" else "") - + ("[tool.uv.sources]\nmissing = { workspace = true }\n" if name == "unused" else "") - ) - (tmp_path / "packages" / "notes.txt").write_text("not a project") - - validate_workspace_sources(tmp_path / "pyproject.toml") - - -def test_workspace_sources_reject_invalid_patterns(tmp_path: Path) -> None: - (tmp_path / "pyproject.toml").write_text("[tool.uv.workspace]\nmembers = [1]\n") - - with pytest.raises(ValueError, match="workspace patterns must be strings"): - validate_workspace_sources(tmp_path / "pyproject.toml") - - -def test_workspace_sources_reject_member_outside_project(tmp_path: Path) -> None: - project = tmp_path / "project" - project.mkdir() - (project / "pyproject.toml").write_text('[tool.uv.workspace]\nmembers = ["external"]\n') - outside = tmp_path / "external" - outside.mkdir() - (outside / "pyproject.toml").write_text("[dependency-groups]\nrelease-hidden = []\n") - (project / "external").symlink_to(outside, target_is_directory=True) - - with pytest.raises(ValueError, match="workspace members must be inside the project"): - validate_workspace_sources(project / "pyproject.toml") - - -@pytest.mark.parametrize( - "source", - [ - pytest.param("missing = { workspace = true }", id="undeclared-member"), - pytest.param('bare = { git = "https://example.org/bare" }', id="git-source"), - pytest.param('bare = { workspace = true, git = "https://example.org/bare" }', id="mixed-source"), - ], -) -def test_lock_check_rejects_unapproved_workspace_sources(tmp_path: Path, source: str) -> None: - (tmp_path / "pyproject.toml").write_text( - '[project]\nname = "bare"\nrequires-python = ">=3.15"\n' - '[tool.uv.workspace]\nmembers = ["meta"]\n[dependency-groups]\nrelease-news = []\n' - ) - member = tmp_path / "meta" - member.mkdir() - (member / "pyproject.toml").write_text( - f'[project]\nname = "meta"\nrequires-python = ">=3.15"\n[tool.uv.sources]\n{source}\n' - ) - lock = tmp_path / "pylock.toml" - lock.write_text( - 'lock-version = "1.0"\ncreated-by = "release-creator"\nrequires-python = ">=3.15"\n' - 'dependency-groups = ["project-runtime", "release-news"]\n' - "default-groups = []\npackages = []\n[tool.release-creator]\ncreated-at = 2026-10-20T00:00:00Z\n" - ) - - result = subprocess.run( - [ - sys.executable, - str(_TOOLS / "check_lock_groups.py"), - str(lock), - "release-news", - "--project", - str(tmp_path / "pyproject.toml"), - ], - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode != 0 - assert "uv source must name a workspace project" in result.stderr diff --git a/tests/refresh_release_lock/test_refresh.py b/tests/refresh_release_lock/test_refresh.py index baf2d3e..3decc32 100644 --- a/tests/refresh_release_lock/test_refresh.py +++ b/tests/refresh_release_lock/test_refresh.py @@ -262,7 +262,7 @@ def test_refresh_installs_selected_nab( [ pytest.param("validate-news/action.yml", ("runs", "steps"), id="validate-news"), pytest.param("check-tagged-source/action.yml", ("runs", "steps"), id="check-tagged-source"), - pytest.param(".github/workflows/prepare-towncrier-release.yaml", ("jobs", "assemble", "steps"), id="prepare"), + pytest.param(".github/workflows/prepare-towncrier-release.yaml", ("jobs", "lock", "steps"), id="prepare"), pytest.param(".github/workflows/upgrade-release-tools.yaml", ("jobs", "generate-lock", "steps"), id="upgrade"), pytest.param(".github/workflows/validate-python-release.yaml", ("jobs", "lock", "steps"), id="python-release"), ], diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index fdb1ab3..50bb965 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -3,30 +3,11 @@ from __future__ import annotations import argparse -import json -import re -import ssl -import time import tomllib -from collections import defaultdict -from collections.abc import Iterable, Iterator, Mapping -from concurrent.futures import ThreadPoolExecutor -from datetime import UTC, datetime, timedelta -from http.client import IncompleteRead from pathlib import Path -from typing import Final, cast -from urllib.error import HTTPError, URLError -from urllib.parse import urlsplit -from urllib.request import Request, urlopen -import certifi -from packaging._parser import Variable -from packaging.markers import Marker -from packaging.pylock import Package, Pylock, PylockValidationError +from packaging.pylock import Pylock, PylockValidationError from packaging.utils import canonicalize_name -from packaging.version import InvalidVersion - -_DURATION: Final = re.compile(r"P(?P[0-9]+)D") def main() -> None: @@ -34,8 +15,6 @@ def main() -> None: parser.add_argument("lock", type=Path) parser.add_argument("groups", nargs="*") parser.add_argument("--build-constraints", action="store_true") - parser.add_argument("--verify-pypi", action="store_true") - parser.add_argument("--project", type=Path) args = parser.parse_args() try: groups = {"build"} if args.build_constraints else set(args.groups) @@ -47,276 +26,18 @@ def main() -> None: name for name in {*groups, *(lock.dependency_groups or ())} if canonicalize_name(name) != name ): raise ValueError(f"dependency group names must be normalized: {', '.join(unnormalized)}") - nab: dict = {} - if args.project: - if (args.project.parent / "nab.toml").exists(): - raise ValueError("project nab.toml can override the reviewed index policy") - with args.project.open("rb") as stream: - nab = tomllib.load(stream).get("tool", {}).get("nab", {}) - _validate_indexes(nab) - if lock_provider(lock.tool) == "release-creator": - validate_workspace_sources(args.project) if (missing := groups - set(lock.dependency_groups or ())) and not ( args.build_constraints and missing == {"build"} ): parser.error(f"missing lock dependency groups: {', '.join(sorted(missing))}") - if args.build_constraints and "build" not in (lock.dependency_groups or ()): - groups.clear() - _check_cooling(lock, groups, nab) - if args.verify_pypi: - _verify_pypi(lock, groups) - except (IncompleteRead, InvalidVersion, KeyError, OSError, PylockValidationError, TypeError, ValueError) as error: + except (KeyError, OSError, PylockValidationError, TypeError, ValueError) as error: parser.error(str(error)) -def lock_provider(tool: Mapping[str, object] | None) -> str: - if "release-creator" in (tool or {}): - return "release-creator" - if "nab" in (tool or {}): - return "nab" - raise ValueError("lock has no recognized producer") - - -def validate_workspace_sources(project: Path) -> None: - with project.open("rb") as stream: - root = tomllib.load(stream) - workspace = root.get("tool", {}).get("uv", {}).get("workspace", {}) - members = workspace.get("members", []) - excluded = workspace.get("exclude", []) - if not all( - isinstance(patterns, list) and all(isinstance(pattern, str) for pattern in patterns) - for patterns in (members, excluded) - ): - raise ValueError("workspace patterns must be strings") - directory = project.parent.resolve() - - def matched(patterns: list[str]) -> set[Path]: - paths = {path.resolve() for pattern in patterns for path in directory.glob(pattern)} - if any(not path.is_relative_to(directory) for path in paths): - raise ValueError("workspace members must be inside the project") - return paths - - projects = [root] - for member in sorted(matched(members) - matched(excluded)): - if member.is_dir(): - with (member / "pyproject.toml").open("rb") as stream: - projects.append(tomllib.load(stream)) - names = { - _normalized_name(name) for item in projects if isinstance(name := item.get("project", {}).get("name"), str) - } - for item in projects: - for name, source in item.get("tool", {}).get("uv", {}).get("sources", {}).items(): - if source != {"workspace": True} or _normalized_name(name) not in names: - raise ValueError(f"uv source must name a workspace project: {name}") - - -def _normalized_name(name: str) -> str: - return "-".join(filter(None, name.casefold().replace("_", "-").replace(".", "-").split("-"))) - - -def _validate_indexes(nab: dict) -> None: - if any(index != {"name": "pypi", "url": "https://pypi.org/simple/"} for index in nab.get("indexes", [])): - raise ValueError("custom Nab indexes are not approved for release locks") - if nab.get("index") or any("index" in settings for settings in nab.get("packages", {}).values()): - raise ValueError("custom Nab indexes are not approved for release locks") - if any("index" in settings for settings in nab.get("package-rules", [])): - raise ValueError("custom Nab indexes are not approved for release locks") - - -def _check_cooling(lock: Pylock, groups: set[str], nab: dict) -> None: - producer = (lock.tool or {}).get(lock_provider(lock.tool)) - if not isinstance(producer, dict): - raise TypeError("lock producer settings must be a table") - created = producer.get("created-at") - if not isinstance(created, datetime) or created.tzinfo is None or created > datetime.now(tz=UTC): - raise ValueError("lock has no valid creation time or was created in the future") - threshold = created - timedelta(days=7) - cutoffs = _package_cutoffs(nab, created) - whole_seconds = lock.created_by == "release-creator (uv)" and lock_provider(lock.tool) == "release-creator" - for package in _selected_packages(lock, groups): - if package.archive is not None or package.vcs is not None or package.directory is not None: - raise ValueError(f"release lock has a direct-source package: {package.name}") - if package.index is None: - raise ValueError(f"release lock has an uncooled non-index package: {package.name}") - if package.index != "https://pypi.org/simple/": - raise ValueError(f"release lock has an unapproved index: {package.name}: {package.index}") - artifacts = ([package.sdist] if package.sdist is not None else []) + list(package.wheels or ()) - cutoff = max(threshold, cutoffs.get(canonicalize_name(package.name), threshold)) - for artifact in artifacts: - url = urlsplit(artifact.url or "") - if artifact.path is not None or url.scheme != "https" or url.netloc != "files.pythonhosted.org": - raise ValueError(f"release lock has a non-PyPI artifact: {artifact.name}") - if ( - artifact.upload_time is None - or artifact.upload_time.tzinfo is None - or artifact.upload_time + timedelta(seconds=whole_seconds) > min(cutoff, created) - ): - raise ValueError(f"release lock has an uncooled artifact: {artifact.name}") - - -def _package_cutoffs(nab: dict, created: datetime) -> dict[str, datetime]: - overrides = [ - (name, settings["uploaded-prior-to"]) - for name, settings in nab.get("packages", {}).items() - if "uploaded-prior-to" in settings - ] - overrides.extend( - (name, rule["uploaded-prior-to"]) - for rule in nab.get("package-rules", []) - if "uploaded-prior-to" in rule - for name in rule["match"] - ) - cutoffs: dict[str, datetime] = {} - for name, value in overrides: - cutoff = _cutoff(name, value, created) - key = canonicalize_name(name) - cutoffs[key] = max(cutoff, cutoffs.get(key, cutoff)) - return cutoffs - - -def _cutoff(name: str, value: object, created: datetime) -> datetime: - if value is False: - return created - if isinstance(value, str) and (match := _DURATION.fullmatch(value)) is not None: - return created - timedelta(days=int(match["days"])) - if isinstance(value, str): - try: - value = datetime.fromisoformat(value) - except ValueError as error: - raise ValueError(f"invalid Nab cutoff for {name}: {value}") from error - if not isinstance(value, datetime) or value.tzinfo is None: - raise ValueError(f"invalid Nab cutoff for {name}: {value}") - return value - - -def _verify_pypi(lock: Pylock, groups: set[str]) -> None: - expected: dict[str, list[tuple[str, str | None, str, datetime | None]]] = defaultdict(list) - for package in _selected_packages(lock, groups): - for artifact in ([package.sdist] if package.sdist is not None else []) + list(package.wheels or ()): - expected[canonicalize_name(package.name)].append( - ( - urlsplit(artifact.url or "").path.rsplit("/", 1)[-1], - artifact.url, - artifact.hashes["sha256"], - artifact.upload_time, - ) - ) - whole_seconds = lock.created_by == "release-creator (uv)" and lock_provider(lock.tool) == "release-creator" - with ThreadPoolExecutor(max_workers=8) as pool: - for _ in pool.map( - _verify_package_metadata, ((name, artifacts, whole_seconds) for name, artifacts in expected.items()) - ): - pass - - -def _verify_package_metadata(item: tuple[str, list[tuple[str, str | None, str, datetime | None]], bool]) -> None: - name, artifacts, whole_seconds = item - for attempt in range(5): - _, actual = _pypi_files(name) - mismatch = next( - ( - filename - for filename, url, digest, upload_time in artifacts - if not ( - (record := actual.get(filename)) is not None - and record[:2] == (url, digest) - and ( - record[2] == upload_time - or whole_seconds - and record[2] is not None - and record[2].replace(microsecond=0) == upload_time - ) - ) - ), - None, - ) - if mismatch is None: - return - if attempt < 4: - time.sleep(2**attempt) - raise ValueError(f"release lock differs from PyPI metadata: {name}: {mismatch}") - - -def _selected_packages(lock: Pylock, groups: set[str]) -> Iterator[Package]: - selections = (groups, {"build"}) if "build" in (lock.dependency_groups or ()) else (groups,) - for package in lock.packages: - if package.marker is None or any(_possibly_selected(package.marker, selection) for selection in selections): - yield package - - -def _possibly_selected(marker: Marker, groups: set[str]) -> bool: - def expression(nodes: Iterable[object]) -> bool: - alternatives: list[bool] = [] - current = True - for node in nodes: - if node == "or": - alternatives.append(current) - current = True - elif node != "and": - if isinstance(node, list): - current &= expression(node) - else: - left, operator, right = cast("tuple[object, object, object]", node) - if isinstance(right, Variable) and str(right) == "dependency_groups" and str(operator) == "in": - current &= str(left) in groups - elif ( - isinstance(right, Variable) and str(right) == "dependency_groups" and str(operator) == "not in" - ): - current &= str(left) not in groups - elif isinstance(right, Variable) and str(right) == "extras": - current &= str(operator) == "not in" - return any((*alternatives, current)) - - return expression(marker._markers) - - -def _pypi_files(name: str) -> tuple[str, dict[str, tuple[str | None, str | None, datetime | None]]]: - request = Request( - f"https://pypi.org/simple/{name}/", - headers={"Accept": "application/vnd.pypi.simple.v1+json", "Cache-Control": "no-cache"}, - ) - attempt = 0 - while True: - try: - with urlopen(request, timeout=10, context=ssl.create_default_context(cafile=certifi.where())) as response: - listing = json.load(response) - break - except HTTPError as error: - if (error.code not in (404, 429) and error.code < 500) or attempt == 4: - raise - except IncompleteRead, TimeoutError, URLError: - if attempt == 4: - raise - time.sleep(2**attempt) - attempt += 1 - if ( - not isinstance(listing, dict) - or not isinstance(listing.get("meta"), dict) - or not isinstance(listing.get("files"), list) - or not str(listing["meta"].get("api-version", "")).startswith("1.") - or canonicalize_name(listing.get("name", "")) != name - ): - raise ValueError(f"invalid PyPI listing for {name}") - files: dict[str, tuple[str | None, str | None, datetime | None]] = {} - for file in listing["files"]: - if not isinstance(file, dict): - raise TypeError(f"invalid PyPI listing for {name}") - if file.get("yanked", False): - continue - files[file["filename"]] = ( - file.get("url"), - file.get("hashes", {}).get("sha256"), - datetime.fromisoformat(file["upload-time"]) if "upload-time" in file else None, - ) - return name, files - - if __name__ == "__main__": main() __all__ = [ - "lock_provider", "main", - "validate_workspace_sources", ] From 7ff7450e85f8df8186c013ac5636b94071febb42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:47:30 -0700 Subject: [PATCH 22/35] =?UTF-8?q?=E2=9C=A8=20feat(hooks):=20add=20a=20cool?= =?UTF-8?q?ed-prek=20Action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Action wraps tools/cooled_prek.sh so workflows can reach it. The prepare workflow's prek install-hooks uses it instead of repeating the environment inline. --- .../workflows/prepare-towncrier-release.yaml | 12 ++---- README.md | 8 ++-- changelog.d/+cooled-prek.feature.md | 2 + cooled-prek/action.yml | 17 ++++++++ tests/conftest.py | 1 + tests/cooled_prek/test_cooled_prek.py | 43 +++++++++++++------ 6 files changed, 58 insertions(+), 25 deletions(-) create mode 100644 changelog.d/+cooled-prek.feature.md create mode 100644 cooled-prek/action.yml diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 6f28909..47c8589 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -122,15 +122,9 @@ jobs: exclude-newer-package: ${{ inputs.exclude-newer-package }} - name: Install prek hooks id: hooks - env: - UV_EXCLUDE_NEWER: 7 days - UV_NO_CONFIG: "1" - UV_NO_SOURCES: "1" - run: | - unset UV_CONFIG_FILE UV_INDEX UV_INDEX_URL UV_DEFAULT_INDEX UV_EXTRA_INDEX_URL - unset UV_FIND_LINKS UV_EXCLUDE_NEWER_PACKAGE - unset UV_INSECURE_HOST UV_SYSTEM_CERTS SSL_CERT_FILE SSL_CERT_DIR - prek install-hooks --config .pre-commit-config.yaml + uses: $/cooled-prek + with: + args: install-hooks background: true - id: prepare name: Build changelog diff --git a/README.md b/README.md index 3adb451..5251c4d 100644 --- a/README.md +++ b/README.md @@ -172,10 +172,10 @@ including a workspace member's `pyproject.toml` or a secondary lock. The resulti `prek`; the writer checks patch paths and modes. Its `changed` output is `true` only when the refresh produces a patch. Neither mode pushes a branch or requests a write token. -Every shared `prek` command passes `--config .pre-commit-config.yaml`. Without it, prek prefers a root `prek.toml` and -also runs nested configurations, which no guard checks. `prepare-towncrier` and `validate-release-patch` therefore -reject a `prek.toml` anywhere, a `.pre-commit-config.yml`, and a `.pre-commit-config.yaml` below the root before they -run `prek`. +Every shared `prek` command runs through `tools/cooled_prek.sh`, which the `cooled-prek` Action wraps for workflows, and +passes `--config .pre-commit-config.yaml`. Without it, prek prefers a root `prek.toml` and also runs nested +configurations, which no guard checks. `prepare-towncrier` and `validate-release-patch` therefore reject a `prek.toml` +anywhere, a `.pre-commit-config.yml`, and a `.pre-commit-config.yaml` below the root before they run `prek`. The shared prepare and tagged validator run `refresh-release-lock` in `check` mode before release checks or publishing. `check-tagged-source` bundles the tagged checks for a custom tag publisher: it requires `GITHUB_REF` to be the tag of diff --git a/changelog.d/+cooled-prek.feature.md b/changelog.d/+cooled-prek.feature.md new file mode 100644 index 0000000..7064d33 --- /dev/null +++ b/changelog.d/+cooled-prek.feature.md @@ -0,0 +1,2 @@ +Add the `cooled-prek` Action, which runs prek with the root configuration and hook environments built under the +seven-day cutoff. The prepare workflow now installs its hooks through it. diff --git a/cooled-prek/action.yml b/cooled-prek/action.yml new file mode 100644 index 0000000..d142fcd --- /dev/null +++ b/cooled-prek/action.yml @@ -0,0 +1,17 @@ +name: Run cooled prek +description: Run prek with the root configuration and hook environments built under the seven-day cutoff +inputs: + args: + description: Whitespace-separated prek arguments, such as install-hooks + required: true +runs: + using: composite + steps: + - name: Run cooled prek + shell: bash + env: + ARGS: ${{ inputs.args }} + run: | + set -euo pipefail + read -ra arguments <<<"$ARGS" + "$GITHUB_ACTION_PATH/../tools/cooled_prek.sh" "${arguments[@]}" diff --git a/tests/conftest.py b/tests/conftest.py index 9d2dc32..0f6f3b4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -61,6 +61,7 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: scripts = tmp_path_factory.mktemp("action-steps") for action_name in ( "prepare-towncrier", + "cooled-prek", "validate-release-patch", "commit-and-tag", "render-release-notes", diff --git a/tests/cooled_prek/test_cooled_prek.py b/tests/cooled_prek/test_cooled_prek.py index 25e60ce..ed0b51a 100644 --- a/tests/cooled_prek/test_cooled_prek.py +++ b/tests/cooled_prek/test_cooled_prek.py @@ -6,9 +6,10 @@ from pathlib import Path import yaml +from conftest import action -def test_every_hook_run_uses_the_root_config() -> None: +def test_no_hook_run_bypasses_cooled_prek() -> None: root = Path(__file__).resolve().parents[2] runs = [ step.get("run", "") @@ -27,25 +28,43 @@ def test_every_hook_run_uses_the_root_config() -> None: for command in re.findall(r"\bprek (?:run|install-hooks|update)\b[^\n]*", run.replace("\\\n", " ")) ] - assert commands - assert all("--config" in command for command in commands), commands + assert not commands, commands -def test_prepare_hook_install_uses_install_time_cutoff() -> None: +def test_prepare_hook_install_uses_cooled_prek() -> None: workflow = yaml.load( (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text(), Loader=yaml.BaseLoader, ) step = next(step for step in workflow["jobs"]["assemble"]["steps"] if step.get("name") == "Install prek hooks") - script = (Path(__file__).resolve().parents[2] / "tools/cooled_prek.sh").read_text() - assert {name: step["env"][name] for name in ("UV_EXCLUDE_NEWER", "UV_NO_CONFIG", "UV_NO_SOURCES")} == { - "UV_EXCLUDE_NEWER": "7 days", - "UV_NO_CONFIG": "1", - "UV_NO_SOURCES": "1", - } - assert [line for line in step["run"].splitlines() if line.startswith("unset ")] == [ - line for line in script.splitlines() if line.startswith("unset ") + assert (step["uses"], step["with"]) == ("$/cooled-prek", {"args": "install-hooks"}) + + +def test_cooled_prek_action_passes_each_argument(tmp_path: Path, action_scripts: Path) -> None: + fake = tmp_path / "bin/prek" + fake.parent.mkdir() + fake.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >"$RUNNER_TEMP/args"\n') + fake.chmod(0o755) + + result = action( + tmp_path, + tmp_path, + action_scripts, + "cooled-prek", + version="", + ARGS="run mdformat --files CHANGELOG.md", + PATH=f"{fake.parent}:{os.environ['PATH']}", + ) + + assert result.returncode == 0, result.stderr + assert (tmp_path / "args").read_text().splitlines() == [ + "--config", + ".pre-commit-config.yaml", + "run", + "mdformat", + "--files", + "CHANGELOG.md", ] From 0334926da5ef5878e4da4250365f76a4c410737c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:48:42 -0700 Subject: [PATCH 23/35] =?UTF-8?q?=F0=9F=94=A5=20feat(notes)!:=20render=20r?= =?UTF-8?q?elease=20notes=20without=20generated=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop GitHub's generate-notes call, its Contents-write job, and the generated-notes input of render-release-notes. gw's notes job is read-only, uses the full history and runs in parallel with the tag check; the release job downloads the notes, re-checks the tag and publishes once. --- .github/workflows/release.yaml | 50 +----------------------- README.md | 22 +++++------ changelog.d/+generated-notes.breaking.md | 3 ++ render-release-notes/action.yml | 7 ---- tests/conftest.py | 1 - tests/release_workflow/test_graph.py | 11 ++++++ tests/render_release_notes/test_notes.py | 22 ----------- 7 files changed, 24 insertions(+), 92 deletions(-) create mode 100644 changelog.d/+generated-notes.breaking.md diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4b33e74..1b5077e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -13,18 +13,12 @@ jobs: permissions: contents: read outputs: - previous: ${{ steps.previous.outputs.previous }} commit: ${{ github.sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - with: - version: latest-known - python-version: "3.15" - enable-cache: false - name: Verify the exact release tag env: TAG: ${{ github.ref_name }} @@ -35,43 +29,7 @@ jobs: test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$GITHUB_SHA" test "$GITHUB_SHA" = "$(git rev-parse HEAD)" git merge-base --is-ancestor HEAD origin/main - - name: Install release tools - env: - EXCLUDE_NEWER: 7 days - run: | - tools/install_live_tools.sh .venv 'release-creator>=2.0.0' - uv run --no-project --python .venv python tools/expose_locked_commands.py .venv - - id: previous - uses: $/select-previous-tag - with: - tag: ${{ github.ref_name }} - contributors: - needs: source - runs-on: ubuntu-26.04 - permissions: - contents: write - outputs: - artifact-id: ${{ steps.artifact.outputs.artifact-id }} - steps: - - name: Fetch contributor metadata - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} - PREVIOUS: ${{ needs.source.outputs.previous }} - run: | - set -euo pipefail - args=(-f "tag_name=$TAG") - if [[ -n "$PREVIOUS" ]]; then args+=(-f "previous_tag_name=$PREVIOUS"); fi - gh api -X POST "repos/$GITHUB_REPOSITORY/releases/generate-notes" "${args[@]}" --jq .body \ - >"$RUNNER_TEMP/generated-notes.md" - - id: artifact - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: generated-notes - path: ${{ runner.temp }}/generated-notes.md - if-no-files-found: error notes: - needs: [source, contributors] runs-on: ubuntu-26.04 permissions: contents: read @@ -80,7 +38,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 1 + fetch-depth: 0 persist-credentials: false - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: @@ -93,16 +51,10 @@ jobs: run: | tools/install_live_tools.sh .venv 'release-creator>=2.0.0' uv run --no-project --python .venv python tools/expose_locked_commands.py .venv - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - artifact-ids: ${{ needs.contributors.outputs.artifact-id }} - path: ${{ runner.temp }} - uses: $/render-release-notes with: tag: ${{ github.ref_name }} - previous: ${{ needs.source.outputs.previous }} changelog: CHANGELOG.md - generated-notes: ${{ runner.temp }}/generated-notes.md - id: artifact uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: diff --git a/README.md b/README.md index 5251c4d..b9e5790 100644 --- a/README.md +++ b/README.md @@ -260,9 +260,11 @@ Publish only after the reusable workflow succeeds, so the lock check gates publi with pinned `download-artifact` [v8.0.2](https://github.com/actions/download-artifact/blob/9000827ccba6bdab643e8b6fd33ac0654aef8333/action.yml#L37-L45), using `artifact-ids: ${{ needs.build.outputs.distributions-artifact-id }}`. The download checks the artifact digest and -selects the wheel and sdist from that build run. Render GitHub notes in the caller's tag workflow after fetching -GitHub's contributor metadata; this reusable workflow does not render notes. Keep PyPI OIDC in the caller's `release` -environment job. +selects the wheel and sdist from that build run. This reusable workflow does not render notes. Render them in a +read-only `notes` job of the caller's tag workflow that runs in parallel with `build`: check out the full history, call +`render-release-notes`, and upload `$RUNNER_TEMP/release-notes.md` as an artifact. The Action finds the previous tag +with `release-creator tags --previous`, renders the section with `release-creator notes`, and appends the artifact link +and the Full Changelog link. Keep PyPI OIDC in the caller's `release` environment job. Run the tag workflow under `concurrency` keyed by the tag, without cancelling a run in progress. The release job has `contents: write` and no checkout. It reconciles the PyPI digests, requires that the tag still resolves to `GITHUB_SHA`, @@ -490,7 +492,7 @@ branch and tag ruleset bypass. A stale branch, invalid tag, or ruleset rejection ## Render GitHub Release notes `render-release-notes` checks the tag and its changelog section, finds the previous tag, and renders an unwrapped GitHub -Release body with `release-creator`. Call it on the actual tag. The publisher still fetches GitHub's generated notes and +Release body with `release-creator`. Call it on the actual tag in a read-only job with the full history. The release job creates the Release; this Action only writes a local Markdown file. ```yaml @@ -499,7 +501,6 @@ creates the Release; this Action only writes a local Markdown file. tag: ${{ github.ref_name }} changelog: CHANGELOG.md artifact-url: https://pypi.org/project/example/${{ github.ref_name }}/ - generated-notes: ${{ runner.temp }}/generated-notes.md ``` - `tag` **(required)**: Release version, such as `1.2.3`. The checkout must contain its changelog section. @@ -508,18 +509,14 @@ creates the Release; this Action only writes a local Markdown file. - `artifact-url`: Ecosystem release URL, such as a PyPI version page. Omit it for a GitHub-only project. - `artifact-label`: Link text for `artifact-url`; default `PyPI release`. Use `Zipapp` or another product name for a different artifact. -- `generated-notes`: Path to GitHub's generated-notes body. The tag publisher fetches it after PyPI publication. It - preserves New Contributors and fills any missing `by @handle` credit. Omit it when the release has no GitHub-generated - contributor metadata. The Action reads `GITHUB_REPOSITORY` for PR and comparison links. A repository with separate release namespaces, such as toml-fmt, calls `release-creator notes --package` directly for its package changelog. **Output:** No named output. **Working-tree effect:** The Action writes only `$RUNNER_TEMP/release-notes.md`. Pass it to `gh release create --notes-file` only after the ecosystem publisher succeeds. It rejects an invalid tag, a missing -heading, an empty body, or a missing comparison link when an earlier tag exists. It neither fetches generated notes nor -writes a GitHub Release, so a PR job needs only read permissions. Fetching generated notes uses GitHub's release -endpoint and needs Contents write in the tag publisher. +heading, an empty body, or a missing comparison link when an earlier tag exists. It does not write a GitHub Release, so +it needs only read permissions. ## Select the previous release tag @@ -535,8 +532,7 @@ checkout or GitHub state. ``` `tag` is required. Install `release-creator` on `PATH` before calling the Action. The `previous` output is empty when -this is the first matching release. Pass it as `previous_tag_name` to GitHub's generated-notes endpoint only when -nonempty. +this is the first matching release. ## Reconcile PyPI artifacts diff --git a/changelog.d/+generated-notes.breaking.md b/changelog.d/+generated-notes.breaking.md new file mode 100644 index 0000000..1eefbb2 --- /dev/null +++ b/changelog.d/+generated-notes.breaking.md @@ -0,0 +1,3 @@ +Remove GitHub's generated notes from the release path: the `generated-notes` input of `render-release-notes` is gone, +and the github-workflows release no longer has a Contents-write job that fetches contributor metadata. The read-only +notes job now runs in parallel with the tag check and renders the notes from the full history alone. diff --git a/render-release-notes/action.yml b/render-release-notes/action.yml index 85fce42..7b84ead 100644 --- a/render-release-notes/action.yml +++ b/render-release-notes/action.yml @@ -13,9 +13,6 @@ inputs: artifact-label: description: Link text for the published artifact default: PyPI release - generated-notes: - description: Existing GitHub generated-notes file with contributor metadata - default: "" previous: description: Previous tag, or auto to calculate it from Git history default: auto @@ -46,7 +43,6 @@ runs: CHANGELOG: ${{ inputs.changelog }} ARTIFACT_URL: ${{ inputs.artifact-url }} ARTIFACT_LABEL: ${{ inputs.artifact-label }} - GENERATED_NOTES: ${{ inputs.generated-notes }} PREVIOUS: ${{ steps.previous.outputs.previous }} run: | set -euo pipefail @@ -57,7 +53,4 @@ runs: if [[ -n "$ARTIFACT_URL" ]]; then args+=(--artifact-url "$ARTIFACT_URL" --artifact-label "$ARTIFACT_LABEL") fi - if [[ -n "$GENERATED_NOTES" ]]; then - args+=(--generated-notes "$GENERATED_NOTES") - fi release-creator notes "${args[@]}" >"$RUNNER_TEMP/release-notes.md" diff --git a/tests/conftest.py b/tests/conftest.py index 0f6f3b4..7c01c2a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -250,7 +250,6 @@ def action( "ARTIFACT_URL": "", "ARTIFACT_LABEL": "PyPI release", "LOCK": "pylock.toml", - "GENERATED_NOTES": "", "MODE": "complete", "RUN_ID": "42", "WORKFLOW_PATH": ".github/workflows/check.yaml", diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index ba02cdb..2b26e66 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -149,3 +149,14 @@ def test_release_writer_requires_the_tag_at_the_event_commit( ) assert (result.returncode == 0) is accepted, result.stderr + + +def test_release_notes_render_read_only_from_full_history() -> None: + workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) + notes = workflow["jobs"]["notes"] + checkout = next(step for step in notes["steps"] if "checkout" in step.get("uses", "")) + + assert notes["permissions"] == {"contents": "read"} + assert "needs" not in notes + assert checkout["with"]["fetch-depth"] == 0 + assert set(workflow["jobs"]) == {"source", "notes", "publish"} diff --git a/tests/render_release_notes/test_notes.py b/tests/render_release_notes/test_notes.py index dac8b70..7322636 100644 --- a/tests/render_release_notes/test_notes.py +++ b/tests/render_release_notes/test_notes.py @@ -64,28 +64,6 @@ def test_render_release_notes_uses_tagged_changelog( assert "previous=0.1.0" in (tmp_path / "output").read_text() -def test_render_release_notes_uses_generated_contributors( - tagged_repository: Path, tmp_path: Path, action_scripts: Path -) -> None: - generated = tmp_path / "generated.md" - generated.write_text( - "* Add a new feature by @gaborbernat in https://github.com/tox-dev/fixture/pull/2\n" - "\n## New Contributors\n* @gaborbernat made their first contribution in " - "https://github.com/tox-dev/fixture/pull/2\n", - ) - result = action( - tagged_repository, - tmp_path, - action_scripts, - "render-release-notes", - version="0.2.0", - TAG="0.2.0", - GENERATED_NOTES=str(generated), - ) - assert result.returncode == 0, result.stderr - assert "### New Contributors\n\n- @gaborbernat" in (tmp_path / "release-notes.md").read_text() - - def test_render_release_notes_accepts_first_release( tagged_repository: Path, tmp_path: Path, action_scripts: Path ) -> None: From ff93ab6583cf0e526e383e297860bb29dcb08b50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:49:34 -0700 Subject: [PATCH 24/35] =?UTF-8?q?=E2=9C=A8=20feat(prepare):=20add=20new=20?= =?UTF-8?q?contributors=20to=20the=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prepare-towncrier calls release-creator's New Contributors step after the Towncrier build. The command name is a placeholder until release-creator 3.1.0 ships it; the tests stand in for it with a shim. --- README.md | 4 ++++ changelog.d/+new-contributors.feature.md | 2 ++ prepare-towncrier/action.yml | 10 ++++++++++ tests/conftest.py | 5 ++++- tests/prepare_towncrier/test_prepare.py | 14 ++++++++++++++ tests/release_creator_shim/release-creator | 8 ++++++++ 6 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 changelog.d/+new-contributors.feature.md create mode 100755 tests/release_creator_shim/release-creator diff --git a/README.md b/README.md index b9e5790..47588fe 100644 --- a/README.md +++ b/README.md @@ -405,6 +405,10 @@ path, fragment directory, categories, and Markdown or RST format from committed `read-towncrier-config`. It does not commit or push. Version selection, fragment linking, the Towncrier build, and formatting must all succeed. +After the Towncrier build, the Action runs release-creator's step that adds a "New Contributors" subsection to the new +changelog section. That step arrives in release-creator 3.1.0; the command name `release-creator new-contributors` and +its options are placeholders until it ships. + ```yaml - id: prepare uses: tox-dev/github-workflows/prepare-towncrier@SHA diff --git a/changelog.d/+new-contributors.feature.md b/changelog.d/+new-contributors.feature.md new file mode 100644 index 0000000..4359515 --- /dev/null +++ b/changelog.d/+new-contributors.feature.md @@ -0,0 +1,2 @@ +Run release-creator's new "New Contributors" step after the Towncrier build in `prepare-towncrier`, so the changelog +section credits first-time contributors. The command name is a placeholder until release-creator 3.1.0 ships it. diff --git a/prepare-towncrier/action.yml b/prepare-towncrier/action.yml index e203db5..aae0718 100644 --- a/prepare-towncrier/action.yml +++ b/prepare-towncrier/action.yml @@ -93,6 +93,16 @@ runs: ;; esac towncrier build --yes --version "$CHANGELOG_PREFIX${VERSION#v}" + - name: Add new contributors to the changelog + if: inputs.phase != 'finalize' + shell: bash + env: + VERSION: ${{ steps.version.outputs.version }} + CHANGELOG: ${{ steps.config.outputs.changelog }} + REPOSITORY: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + # Pending: release-creator 3.1.0 adds this step; the command name and options are placeholders until it ships. + run: release-creator new-contributors --changelog "$CHANGELOG" --version "$VERSION" --repository "$REPOSITORY" - name: Reject other hook configurations if: inputs.phase != 'build' shell: bash diff --git a/tests/conftest.py b/tests/conftest.py index 7c01c2a..3addda3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -377,7 +377,10 @@ def run(directory: Path, *command: str) -> subprocess.CompletedProcess[str]: def _tools_path(environment: dict[str, str]) -> str: - return f"{Path(__file__).resolve().parents[1] / '.release-tools/bin'}:{Path(sys.executable).parent}:{environment['PATH']}" + tools = Path(__file__).resolve().parents[1] / ".release-tools/bin" + return ( + f"{Path(__file__).parent / 'release_creator_shim'}:{tools}:{Path(sys.executable).parent}:{environment['PATH']}" + ) def _condition( diff --git a/tests/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py index 84d7024..3ccbb9a 100644 --- a/tests/prepare_towncrier/test_prepare.py +++ b/tests/prepare_towncrier/test_prepare.py @@ -406,3 +406,17 @@ def test_prepare_rejects_other_hook_configuration( assert result.returncode != 0 assert f"only in the root .pre-commit-config.yaml: {path}" in result.stderr + + +def test_prepare_adds_new_contributors_after_building(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="build") + + assert result.returncode == 0, result.stderr + assert (tmp_path / "new-contributors").read_text().splitlines() == [ + "--changelog", + "CHANGELOG.md", + "--version", + "0.2.0", + "--repository", + "tox-dev/fixture", + ] diff --git a/tests/release_creator_shim/release-creator b/tests/release_creator_shim/release-creator new file mode 100755 index 0000000..e90cfd3 --- /dev/null +++ b/tests/release_creator_shim/release-creator @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# release-creator 3.1.0 adds new-contributors; until it ships, the tests record the call and run the rest for real. +set -euo pipefail +if [[ "$1" == new-contributors ]]; then + printf '%s\n' "${@:2}" >"$RUNNER_TEMP/new-contributors" + exit 0 +fi +exec "$(dirname "$0")/../../.release-tools/bin/release-creator" "$@" From 1eb93b76ba012309580e1d6337c0936d06365c8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:50:58 -0700 Subject: [PATCH 25/35] =?UTF-8?q?=F0=9F=94=A7=20build:=20lock=20gw's=20own?= =?UTF-8?q?=20release-prepare=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a release-prepare group and a universal [tool.nab] config with a 3.15 matrix and the seven-day cutoff, and lock it with nab lock --all-groups --all-extras --no-cache. A temporary release-creator cutoff of 2026-10-25T07:08:32Z admits 2.0.0 until 2026-11-01. --- changelog.d/+own-prepare-lock.packaging.md | 2 + pylock.toml | 1423 ++++++++++++++++++++ pyproject.toml | 13 + 3 files changed, 1438 insertions(+) create mode 100644 changelog.d/+own-prepare-lock.packaging.md create mode 100644 pylock.toml diff --git a/changelog.d/+own-prepare-lock.packaging.md b/changelog.d/+own-prepare-lock.packaging.md new file mode 100644 index 0000000..0651013 --- /dev/null +++ b/changelog.d/+own-prepare-lock.packaging.md @@ -0,0 +1,2 @@ +Lock github-workflows' own `release-prepare` tools in a small `pylock.toml` made by Nab under the seven-day cutoff, +so its release preparation installs them hash-checked from its own lock like every caller. diff --git a/pylock.toml b/pylock.toml new file mode 100644 index 0000000..c9155e8 --- /dev/null +++ b/pylock.toml @@ -0,0 +1,1423 @@ +lock-version = "1.0" +environments = [ + "python_version == \"3.15\" and sys_platform == \"linux\" and platform_machine == \"x86_64\" and implementation_name == \"cpython\" and platform_python_implementation == \"CPython\"", +] +requires-python = ">=3.15" +dependency-groups = [ + "release-prepare", + "test", +] +created-by = "nab" + +[[packages]] +name = "cachetools" +version = "7.2.3" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "cachetools-7.2.3.tar.gz" +upload-time = 2026-10-17 11:41:25.471200+00:00 +url = "https://files.pythonhosted.org/packages/09/88/27fe0785c7a9aa285c753db0b4ea967adbf76128fede66379c35a4b8d054/cachetools-7.2.3.tar.gz" +size = 43727 + +[packages.sdist.hashes] +sha256 = "e6fafd06de4a5bd915ff7d247588ea497e28c9a568c62db8678d9a5f11515a9a" + +[[packages.wheels]] +name = "cachetools-7.2.3-py3-none-any.whl" +upload-time = 2026-10-17 11:41:24.161807+00:00 +url = "https://files.pythonhosted.org/packages/fb/dd/044062b61d94c59c29ad0da6918e6a21106f5b115096e9c87081cdd96167/cachetools-7.2.3-py3-none-any.whl" +size = 18352 + +[packages.wheels.hashes] +sha256 = "85ef9d2703918bedf7f88caa0b06bb3bb7a71360eb0b05c20796b89fede6e30c" + +[[packages]] +name = "certifi" +version = "2026.10.18" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "certifi-2026.10.18.tar.gz" +upload-time = 2026-10-18 16:21:07.655710+00:00 +url = "https://files.pythonhosted.org/packages/7e/eb/a2aa1ac3ea30dd3f30ca57f40232138b6b0040719316ecb85daf61167771/certifi-2026.10.18.tar.gz" +size = 135227 + +[packages.sdist.hashes] +sha256 = "b9995d97b64d0431a5af837d8ca8cd6238104d1f319f04bd93ea43124d6c861e" + +[[packages.wheels]] +name = "certifi-2026.10.18-py3-none-any.whl" +upload-time = 2026-10-18 16:21:06.276884+00:00 +url = "https://files.pythonhosted.org/packages/08/ba/95dd616042484f7c83a06974388b37b837732740ee70bc5dc715b5fcd55a/certifi-2026.10.18-py3-none-any.whl" +size = 134093 + +[packages.wheels.hashes] +sha256 = "73191c47aaa79da394e8fee2dbc2ec827432cab56311122c9e47abf79e390f62" + +[[packages]] +name = "cffi" +version = "2.1.1" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "pycparser" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "cffi-2.1.1.tar.gz" +upload-time = 2026-08-03 21:21:18.939988+00:00 +url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz" +size = 530807 + +[packages.sdist.hashes] +sha256 = "dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be" + +[[packages.wheels]] +name = "cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-08-03 21:20:57.336082+00:00 +url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 221630 + +[packages.wheels.hashes] +sha256 = "aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399" + +[[packages]] +name = "click" +version = "8.5.0" +marker = "\"release-prepare\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "click-8.5.0.tar.gz" +upload-time = 2026-08-26 13:33:14.560531+00:00 +url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz" +size = 382235 + +[packages.sdist.hashes] +sha256 = "ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34" + +[[packages.wheels]] +name = "click-8.5.0-py3-none-any.whl" +upload-time = 2026-08-26 13:33:12.928043+00:00 +url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl" +size = 125251 + +[packages.wheels.hashes] +sha256 = "255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360" + +[[packages]] +name = "colorama" +version = "0.4.6" +marker = "\"test\" in dependency_groups" +requires-python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "colorama-0.4.6.tar.gz" +upload-time = 2022-10-25 02:36:22.414799+00:00 +url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz" +size = 27697 + +[packages.sdist.hashes] +sha256 = "08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" + +[[packages.wheels]] +name = "colorama-0.4.6-py2.py3-none-any.whl" +upload-time = 2022-10-25 02:36:20.889702+00:00 +url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl" +size = 25335 + +[packages.wheels.hashes] +sha256 = "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" + +[[packages]] +name = "coverage" +version = "7.16.2" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "coverage-7.16.2.tar.gz" +upload-time = 2026-09-27 12:29:01.118768+00:00 +url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz" +size = 971999 + +[packages.sdist.hashes] +sha256 = "ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa" + +[[packages.wheels]] +name = "coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl" +upload-time = 2026-09-27 12:28:05.150722+00:00 +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" +size = 259106 + +[packages.wheels.hashes] +sha256 = "8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843" + +[[packages.wheels]] +name = "coverage-7.16.2-py3-none-any.whl" +upload-time = 2026-09-27 12:28:59.075376+00:00 +url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl" +size = 215754 + +[packages.wheels.hashes] +sha256 = "11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f" + +[[packages]] +name = "cryptography" +version = "50.0.2" +marker = "\"test\" in dependency_groups" +requires-python = "!=3.9.0,!=3.9.1,>=3.9" +dependencies = [ + { name = "cffi" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "cryptography-50.0.2.tar.gz" +upload-time = 2026-09-30 15:30:04.884601+00:00 +url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz" +size = 880623 + +[packages.sdist.hashes] +sha256 = "7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-09-30 14:43:49.010540+00:00 +url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 4719841 + +[packages.wheels.hashes] +sha256 = "630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl" +upload-time = 2026-09-30 14:43:55.272150+00:00 +url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl" +size = 4753050 + +[packages.wheels.hashes] +sha256 = "4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl" +upload-time = 2026-09-30 14:44:04.545137+00:00 +url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl" +size = 4752576 + +[packages.wheels.hashes] +sha256 = "9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-09-30 14:44:45.769143+00:00 +url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 4966465 + +[packages.wheels.hashes] +sha256 = "f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl" +upload-time = 2026-09-30 14:44:53.379605+00:00 +url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl" +size = 5001199 + +[packages.wheels.hashes] +sha256 = "58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl" +upload-time = 2026-09-30 14:45:05.009031+00:00 +url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl" +size = 5000859 + +[packages.wheels.hashes] +sha256 = "e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-09-30 15:29:26.807001+00:00 +url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 4749145 + +[packages.wheels.hashes] +sha256 = "ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl" +upload-time = 2026-09-30 15:29:32.605292+00:00 +url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl" +size = 4781387 + +[packages.wheels.hashes] +sha256 = "f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl" +upload-time = 2026-09-30 15:29:41.251208+00:00 +url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl" +size = 4780973 + +[packages.wheels.hashes] +sha256 = "1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c" + +[[packages]] +name = "distlib" +version = "0.4.4" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "distlib-0.4.4.tar.gz" +upload-time = 2026-10-16 13:56:34.203008+00:00 +url = "https://files.pythonhosted.org/packages/68/bc/d36966de7304047a1001a7559fbf266c464bb5d8cc85464536ef8c9bcc32/distlib-0.4.4.tar.gz" +size = 621304 + +[packages.sdist.hashes] +sha256 = "ca4175b4ac7f71223cc30ed7248c45d2b38c2439d8ee6e498ab67ed63cf3508d" + +[[packages.wheels]] +name = "distlib-0.4.4-py2.py3-none-any.whl" +upload-time = 2026-10-16 13:56:32.053304+00:00 +url = "https://files.pythonhosted.org/packages/c1/2b/238f02a4927fa2e27aa995371d240a468b52e42eae44a678434aff736b38/distlib-0.4.4-py2.py3-none-any.whl" +size = 472099 + +[packages.wheels.hashes] +sha256 = "cf09d55b1e85a89576b036aeb71550bfd68320f3f21761ca329eadadad918638" + +[[packages]] +name = "docutils" +version = "0.23" +marker = "\"release-prepare\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "docutils-0.23.tar.gz" +upload-time = 2026-05-27 17:41:06.934109+00:00 +url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz" +size = 2303823 + +[packages.sdist.hashes] +sha256 = "746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e" + +[[packages.wheels]] +name = "docutils-0.23-py3-none-any.whl" +upload-time = 2026-05-27 17:40:58.442528+00:00 +url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl" +size = 634701 + +[packages.wheels.hashes] +sha256 = "25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea" + +[[packages]] +name = "execnet" +version = "2.1.2" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "execnet-2.1.2.tar.gz" +upload-time = 2025-11-12 09:56:37.750774+00:00 +url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz" +size = 166622 + +[packages.sdist.hashes] +sha256 = "63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd" + +[[packages.wheels]] +name = "execnet-2.1.2-py3-none-any.whl" +upload-time = 2025-11-12 09:56:36.333212+00:00 +url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl" +size = 40708 + +[packages.wheels.hashes] +sha256 = "67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec" + +[[packages]] +name = "filelock" +version = "4.2.2" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.11" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "filelock-4.2.2.tar.gz" +upload-time = 2026-10-18 07:26:44.624023+00:00 +url = "https://files.pythonhosted.org/packages/d4/08/d8f81287742010e38daf3632380c8d80ede7b4df2716f8ca3d0a2b087d44/filelock-4.2.2.tar.gz" +size = 651339 + +[packages.sdist.hashes] +sha256 = "b29ff2ae9140bb17b3bc77c648dc4f010d9ac0955eccabff4f2b01dde859f787" + +[[packages.wheels]] +name = "filelock-4.2.2-py3-none-any.whl" +upload-time = 2026-10-18 07:26:43.115949+00:00 +url = "https://files.pythonhosted.org/packages/4e/53/35698fff350cb99c49ee01ae5cb1eb4408567792a286314ea984b53b5002/filelock-4.2.2-py3-none-any.whl" +size = 162415 + +[packages.wheels.hashes] +sha256 = "1851d2f14ca2901e98069d68333b33ad7409f56904e17b48cc0c32a0db8ed73a" + +[[packages]] +name = "hatchling" +version = "1.32.4" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "packaging" }, + { name = "pathspec" }, + { name = "pluggy" }, + { name = "tomlkit" }, + { name = "trove-classifiers" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "hatchling-1.32.4.tar.gz" +upload-time = 2026-09-20 22:48:45.398505+00:00 +url = "https://files.pythonhosted.org/packages/f6/97/b5312f01a8c6daf729a9d272dd442e0c546dbcc630495788786c4b567ed0/hatchling-1.32.4.tar.gz" +size = 58065 + +[packages.sdist.hashes] +sha256 = "c4468f73144c054d2aab4ef0f0378c43b9878bf07f8ffd6b79690e970d375f07" + +[[packages.wheels]] +name = "hatchling-1.32.4-py3-none-any.whl" +upload-time = 2026-09-20 22:48:44.190681+00:00 +url = "https://files.pythonhosted.org/packages/5f/80/91f51f439c05d4ec4623c22928ce16a938d6d793bf709477830823497859/hatchling-1.32.4-py3-none-any.whl" +size = 78748 + +[packages.wheels.hashes] +sha256 = "08ecf7548fb48205e7f213d70c71e67b8271b7242093dc3f1da578b42c734a2c" + +[[packages]] +name = "idna" +version = "3.20" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "idna-3.20.tar.gz" +upload-time = 2026-09-17 14:11:04.752193+00:00 +url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz" +size = 216463 + +[packages.sdist.hashes] +sha256 = "a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44" + +[[packages.wheels]] +name = "idna-3.20-py3-none-any.whl" +upload-time = 2026-09-17 14:11:03.168153+00:00 +url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl" +size = 69583 + +[packages.wheels.hashes] +sha256 = "ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c" + +[[packages]] +name = "iniconfig" +version = "2.3.1" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "iniconfig-2.3.1.tar.gz" +upload-time = 2026-10-06 22:48:38.076390+00:00 +url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz" +size = 21209 + +[packages.sdist.hashes] +sha256 = "67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960" + +[[packages.wheels]] +name = "iniconfig-2.3.1-py3-none-any.whl" +upload-time = 2026-10-06 22:48:36.959685+00:00 +url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl" +size = 7552 + +[packages.wheels.hashes] +sha256 = "9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7" + +[[packages]] +name = "jinja2" +version = "3.1.6" +marker = "\"release-prepare\" in dependency_groups" +requires-python = ">=3.7" +dependencies = [ + { name = "markupsafe" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "jinja2-3.1.6.tar.gz" +upload-time = 2025-03-05 20:05:02.478302+00:00 +url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz" +size = 245115 + +[packages.sdist.hashes] +sha256 = "0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" + +[[packages.wheels]] +name = "jinja2-3.1.6-py3-none-any.whl" +upload-time = 2025-03-05 20:05:00.369721+00:00 +url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl" +size = 134899 + +[packages.wheels.hashes] +sha256 = "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" + +[[packages]] +name = "markdown-it-py" +version = "4.2.0" +marker = "\"release-prepare\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "mdurl" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "markdown_it_py-4.2.0.tar.gz" +upload-time = 2026-05-07 12:08:28.360612+00:00 +url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz" +size = 82454 + +[packages.sdist.hashes] +sha256 = "04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49" + +[[packages.wheels]] +name = "markdown_it_py-4.2.0-py3-none-any.whl" +upload-time = 2026-05-07 12:08:27.182789+00:00 +url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl" +size = 91687 + +[packages.wheels.hashes] +sha256 = "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" + +[[packages]] +name = "markupsafe" +version = "3.0.4" +marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "markupsafe-3.0.4.tar.gz" +upload-time = 2026-10-02 23:07:22.290270+00:00 +url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz" +size = 153777 + +[packages.sdist.hashes] +sha256 = "2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6" + +[[packages.wheels]] +name = "markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +upload-time = 2026-10-02 23:06:22.524416+00:00 +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" +size = 23111 + +[packages.wheels.hashes] +sha256 = "925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369" + +[[packages]] +name = "mdformat" +version = "1.0.0" +marker = "\"release-prepare\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "markdown-it-py" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "mdformat-1.0.0.tar.gz" +upload-time = 2025-10-16 12:05:03.695033+00:00 +url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz" +size = 56953 + +[packages.sdist.hashes] +sha256 = "4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928" + +[[packages.wheels]] +name = "mdformat-1.0.0-py3-none-any.whl" +upload-time = 2025-10-16 12:05:02.607432+00:00 +url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl" +size = 53288 + +[packages.wheels.hashes] +sha256 = "bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b" + +[[packages]] +name = "mdformat-gfm" +version = "1.0.0" +marker = "\"release-prepare\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "markdown-it-py" }, + { name = "mdformat" }, + { name = "mdit-py-plugins" }, + { name = "wcwidth" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "mdformat_gfm-1.0.0.tar.gz" +upload-time = 2025-10-16 09:12:22.402429+00:00 +url = "https://files.pythonhosted.org/packages/56/6f/a626ebb142a290474401b67e2d61e73ce096bf7798ee22dfe6270f924b3f/mdformat_gfm-1.0.0.tar.gz" +size = 10112 + +[packages.sdist.hashes] +sha256 = "d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623" + +[[packages.wheels]] +name = "mdformat_gfm-1.0.0-py3-none-any.whl" +upload-time = 2025-10-16 09:12:21.276157+00:00 +url = "https://files.pythonhosted.org/packages/e6/18/6bc2189b744dd383cad03764f41f30352b1278d2205096f77a29c0b327ad/mdformat_gfm-1.0.0-py3-none-any.whl" +size = 10970 + +[packages.wheels.hashes] +sha256 = "7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005" + +[[packages]] +name = "mdit-py-plugins" +version = "0.6.1" +marker = "\"release-prepare\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "markdown-it-py" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "mdit_py_plugins-0.6.1.tar.gz" +upload-time = 2026-05-13 09:03:38.910230+00:00 +url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz" +size = 56114 + +[packages.sdist.hashes] +sha256 = "a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0" + +[[packages.wheels]] +name = "mdit_py_plugins-0.6.1-py3-none-any.whl" +upload-time = 2026-05-13 09:03:37.760000+00:00 +url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl" +size = 66663 + +[packages.wheels.hashes] +sha256 = "214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d" + +[[packages]] +name = "mdurl" +version = "0.1.2" +marker = "\"release-prepare\" in dependency_groups" +requires-python = ">=3.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "mdurl-0.1.2.tar.gz" +upload-time = 2022-08-14 12:40:10.846281+00:00 +url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz" +size = 8729 + +[packages.sdist.hashes] +sha256 = "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + +[[packages.wheels]] +name = "mdurl-0.1.2-py3-none-any.whl" +upload-time = 2022-08-14 12:40:09.779676+00:00 +url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl" +size = 9979 + +[packages.wheels.hashes] +sha256 = "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" + +[[packages]] +name = "packaging" +version = "26.3" +marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "packaging-26.3.tar.gz" +upload-time = 2026-08-04 18:15:28.737071+00:00 +url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz" +size = 313412 + +[packages.sdist.hashes] +sha256 = "94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79" + +[[packages.wheels]] +name = "packaging-26.3-py3-none-any.whl" +upload-time = 2026-08-04 18:15:27.159957+00:00 +url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl" +size = 129956 + +[packages.wheels.hashes] +sha256 = "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" + +[[packages]] +name = "pathspec" +version = "1.1.1" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pathspec-1.1.1.tar.gz" +upload-time = 2026-04-27 01:46:08.907631+00:00 +url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz" +size = 135180 + +[packages.sdist.hashes] +sha256 = "17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a" + +[[packages.wheels]] +name = "pathspec-1.1.1-py3-none-any.whl" +upload-time = 2026-04-27 01:46:07.060850+00:00 +url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl" +size = 57328 + +[packages.wheels.hashes] +sha256 = "a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189" + +[[packages]] +name = "platformdirs" +version = "4.13.4" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.11" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "platformdirs-4.13.4.tar.gz" +upload-time = 2026-10-17 19:31:53.690937+00:00 +url = "https://files.pythonhosted.org/packages/b7/89/dfd269de936e76319cf27787d9eaaca513491f635d49b0fbe7cdb6d3bf6f/platformdirs-4.13.4.tar.gz" +size = 62364 + +[packages.sdist.hashes] +sha256 = "dd96496961684a7f42041923f842ec7494e09f5e87aded99117e1109cd40570e" + +[[packages.wheels]] +name = "platformdirs-4.13.4-py3-none-any.whl" +upload-time = 2026-10-17 19:31:52.293703+00:00 +url = "https://files.pythonhosted.org/packages/58/51/5614d0537dd830ca64d95a2f254bd66bdf3ac61d41cffcccaec62121fcfd/platformdirs-4.13.4-py3-none-any.whl" +size = 32782 + +[packages.wheels.hashes] +sha256 = "502ce08e9d36cf90579ee44e1e764838842fa658e4975ef47344fd053011d2c8" + +[[packages]] +name = "pluggy" +version = "1.7.0" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pluggy-1.7.0.tar.gz" +upload-time = 2026-10-15 09:50:58.343484+00:00 +url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz" +size = 123304 + +[packages.sdist.hashes] +sha256 = "d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8" + +[[packages.wheels]] +name = "pluggy-1.7.0-py3-none-any.whl" +upload-time = 2026-10-15 09:50:56.808462+00:00 +url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl" +size = 27082 + +[packages.wheels.hashes] +sha256 = "7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec" + +[[packages]] +name = "prek" +version = "0.5.5" +marker = "\"release-prepare\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "prek-0.5.5.tar.gz" +upload-time = 2026-10-05 06:08:59.165911+00:00 +url = "https://files.pythonhosted.org/packages/9b/7b/11538b8e6cd7e269261feae62a2d08818fdcf8f43c3b1a253cec01a5488d/prek-0.5.5.tar.gz" +size = 570428 + +[packages.sdist.hashes] +sha256 = "16bf596768bb754779c7d42317e9ca16ac609c0cce7d13bae0829944ab40a26a" + +[[packages.wheels]] +name = "prek-0.5.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-10-05 06:08:48.645837+00:00 +url = "https://files.pythonhosted.org/packages/93/fc/e566be5919eaffef4cc22488f16e4bbfd08961734bd67c06f85d405e4cbc/prek-0.5.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +size = 6269922 + +[packages.wheels.hashes] +sha256 = "73d509c0d9556171de2502bada4962be7a25871e7b23e782549074e1416dc850" + +[[packages]] +name = "pycparser" +version = "3.11" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pycparser-3.11.tar.gz" +upload-time = 2026-10-09 12:56:59.539345+00:00 +url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz" +size = 113796 + +[packages.sdist.hashes] +sha256 = "d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc" + +[[packages.wheels]] +name = "pycparser-3.11-py3-none-any.whl" +upload-time = 2026-10-09 12:56:58.131173+00:00 +url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl" +size = 51178 + +[packages.wheels.hashes] +sha256 = "51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80" + +[[packages]] +name = "pygments" +version = "2.21.0" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pygments-2.21.0.tar.gz" +upload-time = 2026-08-17 08:02:48.824391+00:00 +url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz" +size = 5005329 + +[packages.sdist.hashes] +sha256 = "610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c" + +[[packages.wheels]] +name = "pygments-2.21.0-py3-none-any.whl" +upload-time = 2026-08-17 08:02:44.912148+00:00 +url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl" +size = 1250147 + +[packages.wheels.hashes] +sha256 = "2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9" + +[[packages]] +name = "pyproject-api" +version = "1.12.0" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.11" +dependencies = [ + { name = "packaging" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pyproject_api-1.12.0.tar.gz" +upload-time = 2026-10-18 07:00:59.632994+00:00 +url = "https://files.pythonhosted.org/packages/9a/c6/04bb77bccfa36aabc783a31e335b5441829f5ec9ff16e313b5b7022d81a8/pyproject_api-1.12.0.tar.gz" +size = 28058 + +[packages.sdist.hashes] +sha256 = "2e83226079dc446a8e2357def7622c01c733284e39afc8e332d9f722fe0c2beb" + +[[packages.wheels]] +name = "pyproject_api-1.12.0-py3-none-any.whl" +upload-time = 2026-10-18 07:00:58.102004+00:00 +url = "https://files.pythonhosted.org/packages/b9/b6/ce66a85089985da9b15b0e0e0d668c4b26c994f619e28a11d609454f3d70/pyproject_api-1.12.0-py3-none-any.whl" +size = 14055 + +[packages.wheels.hashes] +sha256 = "1d3cbf37ac4c3a40940e09cbd4374608d25cdd89e6019f7e06fc285afd3ab467" + +[[packages]] +name = "pytest" +version = "9.1.1" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pytest-9.1.1.tar.gz" +upload-time = 2026-06-19 10:58:32.857420+00:00 +url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz" +size = 1636369 + +[packages.sdist.hashes] +sha256 = "1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" + +[[packages.wheels]] +name = "pytest-9.1.1-py3-none-any.whl" +upload-time = 2026-06-19 10:58:31.347074+00:00 +url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl" +size = 386536 + +[packages.wheels.hashes] +sha256 = "37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" + +[[packages]] +name = "pytest-httpserver" +version = "1.2.0" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "werkzeug" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pytest_httpserver-1.2.0.tar.gz" +upload-time = 2026-10-05 19:00:25.597415+00:00 +url = "https://files.pythonhosted.org/packages/37/ff/81c955e8fbb0323e817d178dd653de90c81896e819a5df65c1e1c327155a/pytest_httpserver-1.2.0.tar.gz" +size = 71860 + +[packages.sdist.hashes] +sha256 = "d07744ce2efaafa0575ed3081d416b999c773b09e84aa95065138bedc3e12352" + +[[packages.wheels]] +name = "pytest_httpserver-1.2.0-py3-none-any.whl" +upload-time = 2026-10-05 19:00:26.998803+00:00 +url = "https://files.pythonhosted.org/packages/8d/34/aa11718873f8cd3d9b3c58938e537af0c2d9cb108160357f5db2af6ddd66/pytest_httpserver-1.2.0-py3-none-any.whl" +size = 23392 + +[packages.wheels.hashes] +sha256 = "749937892dbf909c8b703503b491be66fc24073e7988bf9b4d783af4f33da942" + +[[packages]] +name = "pytest-mock" +version = "3.17.0" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "pytest" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pytest_mock-3.17.0.tar.gz" +upload-time = 2026-10-17 16:21:42.435701+00:00 +url = "https://files.pythonhosted.org/packages/97/c2/28b9f5b955ff2bcd4727a6f768e2ca9b586cd7c37b95ea5f7b9b873ac7b3/pytest_mock-3.17.0.tar.gz" +size = 43064 + +[packages.sdist.hashes] +sha256 = "e36576e0985e01027e999891e7a966c67822d40c89ef1b4c8b6e22737e3590b4" + +[[packages.wheels]] +name = "pytest_mock-3.17.0-py3-none-any.whl" +upload-time = 2026-10-17 16:21:41.265482+00:00 +url = "https://files.pythonhosted.org/packages/e8/ea/47fbdc8f37410956fd445720e927589b1e2a47772c5f28109f0ce2fa26d9/pytest_mock-3.17.0-py3-none-any.whl" +size = 11248 + +[packages.wheels.hashes] +sha256 = "1b82aa4b37137248bd687055e109723debeb07b489de1565297dc9589c5f2d6b" + +[[packages]] +name = "pytest-xdist" +version = "3.8.0" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pytest_xdist-3.8.0.tar.gz" +upload-time = 2025-07-01 13:30:59.346894+00:00 +url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz" +size = 88069 + +[packages.sdist.hashes] +sha256 = "7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1" + +[[packages.wheels]] +name = "pytest_xdist-3.8.0-py3-none-any.whl" +upload-time = 2025-07-01 13:30:56.632054+00:00 +url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl" +size = 46396 + +[packages.wheels.hashes] +sha256 = "202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88" + +[[packages]] +name = "python-discovery" +version = "1.6.3" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "filelock" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "python_discovery-1.6.3.tar.gz" +upload-time = 2026-10-16 03:54:29.507753+00:00 +url = "https://files.pythonhosted.org/packages/fc/54/fad125f5c48f7821644ee6f223ebfcedc4e23597c1a16eeb13d33aa4a91a/python_discovery-1.6.3.tar.gz" +size = 88998 + +[packages.sdist.hashes] +sha256 = "a62b301d96cf5489cb96ab023b32e068421867fdd5df2e42eece877869047fa2" + +[[packages.wheels]] +name = "python_discovery-1.6.3-py3-none-any.whl" +upload-time = 2026-10-16 03:54:28.237016+00:00 +url = "https://files.pythonhosted.org/packages/de/ec/643826e4d69b7bdce4213f221e8f5ed06d0a9dd69fa9b33342578d2cc8c8/python_discovery-1.6.3-py3-none-any.whl" +size = 39386 + +[packages.wheels.hashes] +sha256 = "cb7654125e3dcb594269a6feb56ac693f1f13b7c654493a93eba72dc22d18034" + +[[packages]] +name = "pyyaml" +version = "6.0.3" +marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pyyaml-6.0.3.tar.gz" +upload-time = 2025-09-25 21:33:16.546886+00:00 +url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz" +size = 130960 + +[packages.sdist.hashes] +sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" + +[[packages]] +name = "release-creator" +version = "2.0.0" +marker = "\"release-prepare\" in dependency_groups" +requires-python = ">=3.15" +dependencies = [ + { name = "docutils" }, + { name = "markdown-it-py" }, + { name = "mdformat" }, + { name = "mdformat-gfm" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tomli-w" }, + { name = "towncrier" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "release_creator-2.0.0.tar.gz" +upload-time = 2026-10-25 07:08:31.274557+00:00 +url = "https://files.pythonhosted.org/packages/5d/40/55f7a22a3cda929f9efdf09dbb825e888487e658ce14892f2340218335c1/release_creator-2.0.0.tar.gz" +size = 209257 + +[packages.sdist.hashes] +sha256 = "f7ccec9787327a7e12f4a79c15dff470e894d3a88c9bb60eac9c6f1984ee63bf" + +[[packages.wheels]] +name = "release_creator-2.0.0-py3-none-any.whl" +upload-time = 2026-10-25 07:08:29.746110+00:00 +url = "https://files.pythonhosted.org/packages/88/f1/d7103a5c410019207015fc8229f976e8906da90409eddd5e00066bb360ca/release_creator-2.0.0-py3-none-any.whl" +size = 66805 + +[packages.wheels.hashes] +sha256 = "cf93c2395f4a9ba80bd503792c16189fc238c5d82bb90535250de2968d8d2e92" + +[[packages]] +name = "tomli-w" +version = "1.2.0" +marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "tomli_w-1.2.0.tar.gz" +upload-time = 2025-01-15 12:07:24.262974+00:00 +url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz" +size = 7184 + +[packages.sdist.hashes] +sha256 = "2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021" + +[[packages.wheels]] +name = "tomli_w-1.2.0-py3-none-any.whl" +upload-time = 2025-01-15 12:07:22.074224+00:00 +url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl" +size = 6675 + +[packages.wheels.hashes] +sha256 = "188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90" + +[[packages]] +name = "tomlkit" +version = "0.15.2" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "tomlkit-0.15.2.tar.gz" +upload-time = 2026-10-16 04:07:08.166827+00:00 +url = "https://files.pythonhosted.org/packages/08/72/fc01563df95c189a9f65850096bdc205bc2fe37da6c740cd64afaf18d07e/tomlkit-0.15.2.tar.gz" +size = 190522 + +[packages.sdist.hashes] +sha256 = "83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6" + +[[packages.wheels]] +name = "tomlkit-0.15.2-py3-none-any.whl" +upload-time = 2026-10-16 04:07:09.216014+00:00 +url = "https://files.pythonhosted.org/packages/da/46/7d84138a60a8bd796825b2d38ebe92d0d16501ca57d501b7c5d677401bbc/tomlkit-0.15.2-py3-none-any.whl" +size = 52225 + +[packages.wheels.hashes] +sha256 = "05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437" + +[[packages]] +name = "towncrier" +version = "26.9.0" +marker = "\"release-prepare\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "click" }, + { name = "jinja2" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "towncrier-26.9.0.tar.gz" +upload-time = 2026-09-04 12:57:25.341701+00:00 +url = "https://files.pythonhosted.org/packages/92/d4/e85ce614bf45b1a7fe13b82cc687819c2eaac5a1dd94b84834322095fd98/towncrier-26.9.0.tar.gz" +size = 79338 + +[packages.sdist.hashes] +sha256 = "ace9031631c718cc0709107b8755e6e31350eb8acdc877af43df3177addb9a55" + +[[packages.wheels]] +name = "towncrier-26.9.0-py3-none-any.whl" +upload-time = 2026-09-04 12:57:23.986160+00:00 +url = "https://files.pythonhosted.org/packages/82/88/f9340b545dafa64de053798e760515a2736fbb60092cf9aa6b146ccf3ede/towncrier-26.9.0-py3-none-any.whl" +size = 67030 + +[packages.wheels.hashes] +sha256 = "ae4d223e6aadaff98d29b7f09e58d9c9ccf4cd3b455a2d2e0486d432cd8bf660" + +[[packages]] +name = "tox" +version = "4.67.1" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.11" +dependencies = [ + { name = "cachetools" }, + { name = "colorama" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pluggy" }, + { name = "pyproject-api" }, + { name = "python-discovery" }, + { name = "tomli-w" }, + { name = "virtualenv" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "tox-4.67.1.tar.gz" +upload-time = 2026-10-18 07:34:04.348981+00:00 +url = "https://files.pythonhosted.org/packages/63/6e/677274ae411fa13f5c2ea5c633a6abfa0886e949bd2ef53c7e564a0b0b2e/tox-4.67.1.tar.gz" +size = 347211 + +[packages.sdist.hashes] +sha256 = "a68602f64421b199016127364ee47347b958391d19c2b2e53fe95673a8fe98e9" + +[[packages.wheels]] +name = "tox-4.67.1-py3-none-any.whl" +upload-time = 2026-10-18 07:34:02.728361+00:00 +url = "https://files.pythonhosted.org/packages/0c/ac/cfba9de0f43b58d199f47c441efd1b670b55c480f3ec18bc1345f752c3d8/tox-4.67.1-py3-none-any.whl" +size = 238078 + +[packages.wheels.hashes] +sha256 = "23d614cb703e1f7bd39e00c046d4c261b63fb9986e185134c6be2f93531d9726" + +[[packages]] +name = "tox-uv" +version = "1.37.0" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.11" +dependencies = [ + { name = "tox-uv-bare" }, + { name = "uv" }, +] +index = "https://pypi.org/simple/" + +[[packages.wheels]] +name = "tox_uv-1.37.0-py3-none-any.whl" +upload-time = 2026-10-11 00:33:49.804335+00:00 +url = "https://files.pythonhosted.org/packages/2a/65/7cf72148bff33fd0a572e620c7b1573d6a5e4812e9207878d4e29e4a938a/tox_uv-1.37.0-py3-none-any.whl" +size = 6564 + +[packages.wheels.hashes] +sha256 = "f2e7de8f12d7ffd850d2a3dcebf278cf72a8952a9abe75d84cfb54a76ee17ae4" + +[[packages]] +name = "tox-uv-bare" +version = "1.37.0" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.11" +dependencies = [ + { name = "packaging" }, + { name = "tox" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "tox_uv_bare-1.37.0.tar.gz" +upload-time = 2026-10-11 00:33:52.786394+00:00 +url = "https://files.pythonhosted.org/packages/31/27/203b4dbe73cc6dadc2480e319631310981540dabe95893de983106d7b103/tox_uv_bare-1.37.0.tar.gz" +size = 32961 + +[packages.sdist.hashes] +sha256 = "97418f2febec7da57d010ca6ffed8c7d56be9d425f381b74c7fe8d0213fccad9" + +[[packages.wheels]] +name = "tox_uv_bare-1.37.0-py3-none-any.whl" +upload-time = 2026-10-11 00:33:51.338731+00:00 +url = "https://files.pythonhosted.org/packages/52/4c/9596668bb448b868c684a3df9190103e9bc2f7de13e646628ac914f928be/tox_uv_bare-1.37.0-py3-none-any.whl" +size = 22322 + +[packages.wheels.hashes] +sha256 = "a596c0726690e2182cc85dfbc2f39e6fddacb8f7ea08bf8bace122a17397cd13" + +[[packages]] +name = "trove-classifiers" +version = "2026.9.21.13" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "trove_classifiers-2026.9.21.13.tar.gz" +upload-time = 2026-09-21 13:29:07.301512+00:00 +url = "https://files.pythonhosted.org/packages/bf/93/af436dfaa845cab5d96f0adbc1e4f3730532d37fa249e4eb796fb1d7fc82/trove_classifiers-2026.9.21.13.tar.gz" +size = 17085 + +[packages.sdist.hashes] +sha256 = "0a9ebc8d4e2f3e8a22848c5258033035bec17a3012ac3fea16dbaa764489eb71" + +[[packages.wheels]] +name = "trove_classifiers-2026.9.21.13-py3-none-any.whl" +upload-time = 2026-09-21 13:29:06.123578+00:00 +url = "https://files.pythonhosted.org/packages/30/81/0da8afb52a71d0a4f2bd3152357b1a441e393b286374802b9d3addab4ab5/trove_classifiers-2026.9.21.13-py3-none-any.whl" +size = 14232 + +[packages.wheels.hashes] +sha256 = "8b1ff4f9c191b1040b71c37f1e445ab99732911e3cd91de52838453a854d7a17" + +[[packages]] +name = "trustme" +version = "1.2.1" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "cryptography" }, + { name = "idna" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "trustme-1.2.1.tar.gz" +upload-time = 2025-01-02 01:55:32.632349+00:00 +url = "https://files.pythonhosted.org/packages/4b/c5/931476f4cf1cd9e736f32651005078061a50dc164a2569fb874e00eb2786/trustme-1.2.1.tar.gz" +size = 26844 + +[packages.sdist.hashes] +sha256 = "6528ba2bbc7f2db41f33825c8dd13e3e3eb9d334ba0f909713c8c3139f4ae47f" + +[[packages.wheels]] +name = "trustme-1.2.1-py3-none-any.whl" +upload-time = 2025-01-02 01:55:30.181473+00:00 +url = "https://files.pythonhosted.org/packages/b5/f3/c34dbabf6da5eda56fe923226769d40e11806952cd7f46655dd06e10f018/trustme-1.2.1-py3-none-any.whl" +size = 16530 + +[packages.wheels.hashes] +sha256 = "d768e5fc57c86dfc5ec9365102e9b092541cd6954b35d8c1eea01a84f35a762a" + +[[packages]] +name = "ty" +version = "0.0.89" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "ty-0.0.89.tar.gz" +upload-time = 2026-10-16 10:35:05.249809+00:00 +url = "https://files.pythonhosted.org/packages/67/ea/dfb9bdbc168c50a568809466596751afa0c7da60da03b8c12906d1decde4/ty-0.0.89.tar.gz" +size = 7978241 + +[packages.sdist.hashes] +sha256 = "bc1027b1ea95cca25c2a3a0b41433c809cf4b86a384d2b2434eba4bcd34548d4" + +[[packages.wheels]] +name = "ty-0.0.89-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-10-16 10:34:44.063367+00:00 +url = "https://files.pythonhosted.org/packages/3e/61/da22c714fd4f4dff642d60fb0d8bf8da1985d126f21cc89237f8bf707c92/ty-0.0.89-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +size = 14510395 + +[packages.wheels.hashes] +sha256 = "95a410f10b26cc3861757b626727db9d979deba7d2a3ab59f964fac8f0514fdc" + +[[packages]] +name = "uv" +version = "0.13.1" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "uv-0.13.1.tar.gz" +upload-time = 2026-10-14 05:13:35.353145+00:00 +url = "https://files.pythonhosted.org/packages/ef/10/81f499de381ac81e09961e1c19255c5cf49563ac418464def4190b6c7400/uv-0.13.1.tar.gz" +size = 7434239 + +[packages.sdist.hashes] +sha256 = "6df50a86b09be66a413f8f1b16258d585d4dd1cbff591077ae1e6f65c26de2eb" + +[[packages.wheels]] +name = "uv-0.13.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-10-14 05:13:13.069021+00:00 +url = "https://files.pythonhosted.org/packages/14/0b/6602a613533602e7a083e908c4375f9b0e78a94ba2e21c29b21c41a0a611/uv-0.13.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +size = 17279385 + +[packages.wheels.hashes] +sha256 = "696dcf6d807d85da978f4db8472da81dccc1254b18b52cbd1367c4d923c421e0" + +[[packages]] +name = "virtualenv" +version = "21.14.11" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "virtualenv-21.14.11.tar.gz" +upload-time = 2026-10-18 07:06:34.716606+00:00 +url = "https://files.pythonhosted.org/packages/7f/be/4d1d59d309db76b5808fcfd78bd5996f4e7241366691c38fc331a3136398/virtualenv-21.14.11.tar.gz" +size = 5503091 + +[packages.sdist.hashes] +sha256 = "593052d8477ea8c55a842387ce111bd08c72618f0c1133c302cafe335e570a57" + +[[packages.wheels]] +name = "virtualenv-21.14.11-py3-none-any.whl" +upload-time = 2026-10-18 07:06:32.719110+00:00 +url = "https://files.pythonhosted.org/packages/ce/6b/d85da8982b41190eafcb9df91dff9d83fc6989e98af4b5be4c8f85510c95/virtualenv-21.14.11-py3-none-any.whl" +size = 5495268 + +[packages.wheels.hashes] +sha256 = "b319f8590a84e5de5f8f2c803ecd2237e9bc0ffa7c1e875e3169c107cecb7454" + +[[packages]] +name = "wcwidth" +version = "0.9.2" +marker = "\"release-prepare\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "wcwidth-0.9.2.tar.gz" +upload-time = 2026-10-05 00:24:05.521972+00:00 +url = "https://files.pythonhosted.org/packages/f0/b4/7830542634bb2d3e62aa3b586a72d5b3b6c91c3168929e7000ef3fed041d/wcwidth-0.9.2.tar.gz" +size = 955039 + +[packages.sdist.hashes] +sha256 = "ae0ef90b90f6af38b54f1fe6d58662ec33b3cb4b8391958a62416d654231727b" + +[[packages.wheels]] +name = "wcwidth-0.9.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-10-05 00:23:40.175985+00:00 +url = "https://files.pythonhosted.org/packages/db/6c/f940133c71427c208575910e981942bd78c98b1f7cd0d1425ca4b7457c04/wcwidth-0.9.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 769763 + +[packages.wheels.hashes] +sha256 = "751bef0ab404b6a1dc028b56b4b85d46486be1c55833f80da533e42dc691f389" + +[[packages.wheels]] +name = "wcwidth-0.9.2-py3-none-any.whl" +upload-time = 2026-10-05 00:23:21.097514+00:00 +url = "https://files.pythonhosted.org/packages/9c/b4/0bfa065af506540d9d558e3e5548cff00bc1f9b24e6e2a8512498e8628de/wcwidth-0.9.2-py3-none-any.whl" +size = 301667 + +[packages.wheels.hashes] +sha256 = "89ca642c5bf0101157a09366be69fad0379db1f700ae39a920e103234573670e" + +[[packages]] +name = "werkzeug" +version = "3.1.9" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "markupsafe" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "werkzeug-3.1.9.tar.gz" +upload-time = 2026-09-27 18:33:41.637920+00:00 +url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz" +size = 940188 + +[packages.sdist.hashes] +sha256 = "55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060" + +[[packages.wheels]] +name = "werkzeug-3.1.9-py3-none-any.whl" +upload-time = 2026-09-27 18:33:39.685530+00:00 +url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl" +size = 228700 + +[packages.wheels.hashes] +sha256 = "6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab" + +[tool.nab] +nab-version = "0.0.22" +created-at = 2026-10-25 20:50:04.373872+00:00 +command-line = [ + "nab", + "lock", + "--all-groups", + "--all-extras", + "--no-cache", +] +input-path = "pyproject.toml" +mode = "universal" +python-specifier = ">=3.15,<3.16" +platforms = [ + "linux_x86_64", +] diff --git a/pyproject.toml b/pyproject.toml index 43e00bd..b14a842 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ license = "MIT" license-files = [ "LICENSE" ] [dependency-groups] +release-prepare = [ "release-creator>=2.0.0", "towncrier>=26.9", "prek>=0.2" ] test = [ "certifi", "coverage[toml]>=7.10", @@ -24,6 +25,18 @@ test = [ "ty", ] +[tool.nab] +mode = "universal" +uploaded-prior-to = "P7D" + +[tool.nab.matrix] +python = ">=3.15,<3.16" +platforms = ["linux_x86_64"] + +# Temporary: release-creator 2.0.0 is younger than seven days. Delete after 2026-11-01. +[tool.nab.packages.release-creator] +uploaded-prior-to = 2026-10-25T07:08:32Z + [tool.ruff] line-length = 120 From deb18dfb5994ea56aee7d0171a834f62d0f8ea55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:51:25 -0700 Subject: [PATCH 26/35] =?UTF-8?q?=F0=9F=94=A7=20ci:=20admit=20release-crea?= =?UTF-8?q?tor=202.0.0=20in=20gw's=20own=20jobs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gw's own workflows pass exclude-newer-package release-creator=2026-10-25T07:08:32Z, one second after 2.0.0's last upload, to their live installs and the Actions and workflows they call. Delete the override after 2026-11-01. --- .github/workflows/check.yaml | 2 ++ .github/workflows/news.yaml | 5 +++++ .github/workflows/prepare-release.yaml | 2 ++ .github/workflows/release.yaml | 2 ++ changelog.d/+own-prepare-lock.packaging.md | 4 ++-- 5 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index d3cfa71..4b303b1 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -102,6 +102,8 @@ jobs: - name: Install release tools env: EXCLUDE_NEWER: 7 days + # Temporary: release-creator 2.0.0 is younger than seven days. Delete after 2026-11-01. + EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-25T07:08:32Z run: | tools/install_live_tools.sh .release-tools 'release-creator>=2.0.0' 'towncrier>=26.9' 'prek>=0.2' 'yq>=4.4.5' - name: Run Action tests diff --git a/.github/workflows/news.yaml b/.github/workflows/news.yaml index 876ba77..8edd1f3 100644 --- a/.github/workflows/news.yaml +++ b/.github/workflows/news.yaml @@ -22,7 +22,12 @@ jobs: - name: Install release tools env: EXCLUDE_NEWER: 7 days + # Temporary: release-creator 2.0.0 is younger than seven days. Delete after 2026-11-01. + EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-25T07:08:32Z run: | tools/install_live_tools.sh "$RUNNER_TEMP/release-tools" 'release-creator>=2.0.0' 'towncrier>=26.9' uv run --no-project --python "$RUNNER_TEMP/release-tools" python tools/expose_locked_commands.py "$RUNNER_TEMP/release-tools" - uses: $/validate-news + with: + # Temporary: release-creator 2.0.0 is younger than seven days. Delete after 2026-11-01. + exclude-newer-package: release-creator=2026-10-25T07:08:32Z diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml index 88ba696..ddf85ce 100644 --- a/.github/workflows/prepare-release.yaml +++ b/.github/workflows/prepare-release.yaml @@ -17,5 +17,7 @@ jobs: pull-requests: read with: bump: ${{ inputs.bump }} + # Temporary: release-creator 2.0.0 is younger than seven days. Delete after 2026-11-01. + exclude-newer-package: release-creator=2026-10-25T07:08:32Z secrets: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 1b5077e..3b11eba 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -48,6 +48,8 @@ jobs: - name: Install release tools env: EXCLUDE_NEWER: 7 days + # Temporary: release-creator 2.0.0 is younger than seven days. Delete after 2026-11-01. + EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-25T07:08:32Z run: | tools/install_live_tools.sh .venv 'release-creator>=2.0.0' uv run --no-project --python .venv python tools/expose_locked_commands.py .venv diff --git a/changelog.d/+own-prepare-lock.packaging.md b/changelog.d/+own-prepare-lock.packaging.md index 0651013..2ce30b6 100644 --- a/changelog.d/+own-prepare-lock.packaging.md +++ b/changelog.d/+own-prepare-lock.packaging.md @@ -1,2 +1,2 @@ -Lock github-workflows' own `release-prepare` tools in a small `pylock.toml` made by Nab under the seven-day cutoff, -so its release preparation installs them hash-checked from its own lock like every caller. +Lock github-workflows' own `release-prepare` tools in a small `pylock.toml` made by Nab under the seven-day cutoff, so +its release preparation installs them hash-checked from its own lock like every caller. From 334de2aaea4ba064358a490d6f00a404f6f592c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:53:42 -0700 Subject: [PATCH 27/35] =?UTF-8?q?=F0=9F=94=A7=20build:=20lock=20gw's=20val?= =?UTF-8?q?idate=20tools=20and=20build=20backends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the 4.0.1 build and release-validate groups to gw's own lock. PyYAML 6.0.3 has no cp315 wheel and builds with setuptools and Cython, and the prepare workflow's validate job installs release-validate. --- changelog.d/+own-prepare-lock.packaging.md | 5 +- pylock.toml | 93 ++++++++++++++++++---- pyproject.toml | 2 + 3 files changed, 82 insertions(+), 18 deletions(-) diff --git a/changelog.d/+own-prepare-lock.packaging.md b/changelog.d/+own-prepare-lock.packaging.md index 2ce30b6..e701c59 100644 --- a/changelog.d/+own-prepare-lock.packaging.md +++ b/changelog.d/+own-prepare-lock.packaging.md @@ -1,2 +1,3 @@ -Lock github-workflows' own `release-prepare` tools in a small `pylock.toml` made by Nab under the seven-day cutoff, so -its release preparation installs them hash-checked from its own lock like every caller. +Lock github-workflows' own `release-prepare` and `release-validate` tools, with the `build` backends PyYAML needs, in a +small `pylock.toml` made by Nab under the seven-day cutoff, so its release preparation installs them hash-checked from +its own lock like every caller. diff --git a/pylock.toml b/pylock.toml index c9155e8..e86cb72 100644 --- a/pylock.toml +++ b/pylock.toml @@ -4,7 +4,9 @@ environments = [ ] requires-python = ">=3.15" dependency-groups = [ + "build", "release-prepare", + "release-validate", "test", ] created-by = "nab" @@ -90,7 +92,7 @@ sha256 = "aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399" [[packages]] name = "click" version = "8.5.0" -marker = "\"release-prepare\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" @@ -271,6 +273,40 @@ size = 4780973 [packages.wheels.hashes] sha256 = "1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c" +[[packages]] +name = "cython" +version = "3.3.0" +marker = "\"build\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "cython-3.3.0.tar.gz" +upload-time = 2026-08-22 05:16:39.493040+00:00 +url = "https://files.pythonhosted.org/packages/a9/d8/4981ef716ad0e3ff0d3ef383aefc6b03c4a88dee33b272bf8e0d833001ca/cython-3.3.0.tar.gz" +size = 3727515 + +[packages.sdist.hashes] +sha256 = "eed0d93fbca7087f143b42c34b05a825849bdf17f101572c2105acfa49aa88b8" + +[[packages.wheels]] +name = "cython-3.3.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +upload-time = 2026-08-22 05:17:36.538454+00:00 +url = "https://files.pythonhosted.org/packages/c9/aa/4c0b6773ecf6bc1ec6cda7db8312a566611b330fb6dae87d740e44a47822/cython-3.3.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +size = 3454285 + +[packages.wheels.hashes] +sha256 = "b447f6906e0555f05dc4742ef1f99091b1e5d9aa9f16616e772fbf9ff6271616" + +[[packages.wheels]] +name = "cython-3.3.0-py3-none-any.whl" +upload-time = 2026-08-22 05:16:36.741632+00:00 +url = "https://files.pythonhosted.org/packages/bf/77/67b0b24e45073a699610e50f00c18474ff9b09ea29ecc95083bdf5e60acd/cython-3.3.0-py3-none-any.whl" +size = 1349151 + +[packages.wheels.hashes] +sha256 = "9b24b5c8cd536946b62086fcafee6d5509d3f549f72d553d2336af87ffbe0da1" + [[packages]] name = "distlib" version = "0.4.4" @@ -298,7 +334,7 @@ sha256 = "cf09d55b1e85a89576b036aeb71550bfd68320f3f21761ca329eadadad918638" [[packages]] name = "docutils" version = "0.23" -marker = "\"release-prepare\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" @@ -455,7 +491,7 @@ sha256 = "9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7" [[packages]] name = "jinja2" version = "3.1.6" -marker = "\"release-prepare\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" requires-python = ">=3.7" dependencies = [ { name = "markupsafe" }, @@ -483,7 +519,7 @@ sha256 = "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" [[packages]] name = "markdown-it-py" version = "4.2.0" -marker = "\"release-prepare\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "mdurl" }, @@ -511,7 +547,7 @@ sha256 = "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" [[packages]] name = "markupsafe" version = "3.0.4" -marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" @@ -536,7 +572,7 @@ sha256 = "925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369" [[packages]] name = "mdformat" version = "1.0.0" -marker = "\"release-prepare\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "markdown-it-py" }, @@ -564,7 +600,7 @@ sha256 = "bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b" [[packages]] name = "mdformat-gfm" version = "1.0.0" -marker = "\"release-prepare\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "markdown-it-py" }, @@ -595,7 +631,7 @@ sha256 = "7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005" [[packages]] name = "mdit-py-plugins" version = "0.6.1" -marker = "\"release-prepare\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "markdown-it-py" }, @@ -623,7 +659,7 @@ sha256 = "214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d" [[packages]] name = "mdurl" version = "0.1.2" -marker = "\"release-prepare\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" requires-python = ">=3.7" index = "https://pypi.org/simple/" @@ -648,7 +684,7 @@ sha256 = "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" [[packages]] name = "packaging" version = "26.3" -marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" @@ -748,7 +784,7 @@ sha256 = "7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec" [[packages]] name = "prek" version = "0.5.5" -marker = "\"release-prepare\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" requires-python = ">=3.8" index = "https://pypi.org/simple/" @@ -995,7 +1031,7 @@ sha256 = "cb7654125e3dcb594269a6feb56ac693f1f13b7c654493a93eba72dc22d18034" [[packages]] name = "pyyaml" version = "6.0.3" -marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.8" index = "https://pypi.org/simple/" @@ -1011,7 +1047,7 @@ sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" [[packages]] name = "release-creator" version = "2.0.0" -marker = "\"release-prepare\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" requires-python = ">=3.15" dependencies = [ { name = "docutils" }, @@ -1043,10 +1079,35 @@ size = 66805 [packages.wheels.hashes] sha256 = "cf93c2395f4a9ba80bd503792c16189fc238c5d82bb90535250de2968d8d2e92" +[[packages]] +name = "setuptools" +version = "84.0.0" +marker = "\"build\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "setuptools-84.0.0.tar.gz" +upload-time = 2026-08-08 18:27:58.365696+00:00 +url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz" +size = 1168449 + +[packages.sdist.hashes] +sha256 = "f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73" + +[[packages.wheels]] +name = "setuptools-84.0.0-py3-none-any.whl" +upload-time = 2026-08-08 18:27:56.719708+00:00 +url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl" +size = 818216 + +[packages.wheels.hashes] +sha256 = "51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670" + [[packages]] name = "tomli-w" version = "1.2.0" -marker = "\"release-prepare\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" @@ -1096,7 +1157,7 @@ sha256 = "05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437" [[packages]] name = "towncrier" version = "26.9.0" -marker = "\"release-prepare\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" requires-python = ">=3.9" dependencies = [ { name = "click" }, @@ -1346,7 +1407,7 @@ sha256 = "b319f8590a84e5de5f8f2c803ecd2237e9bc0ffa7c1e875e3169c107cecb7454" [[packages]] name = "wcwidth" version = "0.9.2" -marker = "\"release-prepare\" in dependency_groups" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" diff --git a/pyproject.toml b/pyproject.toml index b14a842..f091142 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,9 @@ license = "MIT" license-files = [ "LICENSE" ] [dependency-groups] +build = [ "Cython>=3.0", "setuptools>=80" ] release-prepare = [ "release-creator>=2.0.0", "towncrier>=26.9", "prek>=0.2" ] +release-validate = [ "release-creator>=2.0.0", "prek>=0.2" ] test = [ "certifi", "coverage[toml]>=7.10", From 96ff3deefaf19dd53f436a548b59222322ee624d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:54:05 -0700 Subject: [PATCH 28/35] =?UTF-8?q?=F0=9F=91=B7=20ci:=20refresh=20gw's=20own?= =?UTF-8?q?=20lock=20weekly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore update-tools.yaml, now calling upgrade-release-tools.yaml for gw's small release lock the same way consumers do. --- .github/workflows/update-tools.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/workflows/update-tools.yaml diff --git a/.github/workflows/update-tools.yaml b/.github/workflows/update-tools.yaml new file mode 100644 index 0000000..0ae67d8 --- /dev/null +++ b/.github/workflows/update-tools.yaml @@ -0,0 +1,19 @@ +name: Update release tools +on: + workflow_dispatch: + schedule: + - cron: "17 9 * * 2" +permissions: {} +jobs: + update: + uses: $/.github/workflows/upgrade-release-tools.yaml + permissions: + contents: read + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + with: + commit-message: "🔧 build: refresh release tool lock" + pull-request-title: "🔧 build: refresh release tool lock" + pull-request-body: "Refresh the locked tools used by release preparation and CI." + # Temporary: release-creator 2.0.0 is younger than seven days. Delete after 2026-11-01. + exclude-newer-package: release-creator=2026-10-25T07:08:32Z From 0278f1030f59caa691d6558fb130cd7e4f49efcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 13:54:36 -0700 Subject: [PATCH 29/35] =?UTF-8?q?=E2=9C=A8=20feat(prepare):=20call=20relea?= =?UTF-8?q?se-creator=20contributors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the New Contributors placeholder with the release-creator 3.1.0 command and its --changelog, --version and --repository options. The test shim accepts only that interface until 3.1.0 is on PyPI. --- README.md | 4 ++-- changelog.d/+new-contributors.feature.md | 4 ++-- prepare-towncrier/action.yml | 3 +-- tests/prepare_towncrier/test_prepare.py | 2 +- tests/release_creator_shim/release-creator | 16 +++++++++++++--- 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 47588fe..a4e0f4c 100644 --- a/README.md +++ b/README.md @@ -406,8 +406,8 @@ path, fragment directory, categories, and Markdown or RST format from committed formatting must all succeed. After the Towncrier build, the Action runs release-creator's step that adds a "New Contributors" subsection to the new -changelog section. That step arrives in release-creator 3.1.0; the command name `release-creator new-contributors` and -its options are placeholders until it ships. +changelog section with `release-creator contributors`, which release-creator 3.1.0 adds. The caller's `release-prepare` +group needs that version once it is published. ```yaml - id: prepare diff --git a/changelog.d/+new-contributors.feature.md b/changelog.d/+new-contributors.feature.md index 4359515..6111ff0 100644 --- a/changelog.d/+new-contributors.feature.md +++ b/changelog.d/+new-contributors.feature.md @@ -1,2 +1,2 @@ -Run release-creator's new "New Contributors" step after the Towncrier build in `prepare-towncrier`, so the changelog -section credits first-time contributors. The command name is a placeholder until release-creator 3.1.0 ships it. +Run `release-creator contributors` after the Towncrier build in `prepare-towncrier`, so the changelog section gets a +"New Contributors" subsection. It needs release-creator 3.1.0. diff --git a/prepare-towncrier/action.yml b/prepare-towncrier/action.yml index aae0718..1347e99 100644 --- a/prepare-towncrier/action.yml +++ b/prepare-towncrier/action.yml @@ -101,8 +101,7 @@ runs: CHANGELOG: ${{ steps.config.outputs.changelog }} REPOSITORY: ${{ github.repository }} GH_TOKEN: ${{ github.token }} - # Pending: release-creator 3.1.0 adds this step; the command name and options are placeholders until it ships. - run: release-creator new-contributors --changelog "$CHANGELOG" --version "$VERSION" --repository "$REPOSITORY" + run: release-creator contributors --changelog "$CHANGELOG" --version "$VERSION" --repository "$REPOSITORY" - name: Reject other hook configurations if: inputs.phase != 'build' shell: bash diff --git a/tests/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py index 3ccbb9a..4dcc082 100644 --- a/tests/prepare_towncrier/test_prepare.py +++ b/tests/prepare_towncrier/test_prepare.py @@ -412,7 +412,7 @@ def test_prepare_adds_new_contributors_after_building(repository: Path, tmp_path result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="build") assert result.returncode == 0, result.stderr - assert (tmp_path / "new-contributors").read_text().splitlines() == [ + assert (tmp_path / "contributors").read_text().splitlines() == [ "--changelog", "CHANGELOG.md", "--version", diff --git a/tests/release_creator_shim/release-creator b/tests/release_creator_shim/release-creator index e90cfd3..123900d 100755 --- a/tests/release_creator_shim/release-creator +++ b/tests/release_creator_shim/release-creator @@ -1,8 +1,18 @@ #!/usr/bin/env bash -# release-creator 3.1.0 adds new-contributors; until it ships, the tests record the call and run the rest for real. +# release-creator 3.1.0 adds contributors; until it is on PyPI, the tests record the call and run the rest for real. set -euo pipefail -if [[ "$1" == new-contributors ]]; then - printf '%s\n' "${@:2}" >"$RUNNER_TEMP/new-contributors" +if [[ "$1" == contributors ]]; then + shift + while (($#)); do + case "$1" in + --changelog | --version | --repository) printf '%s\n' "$1" "$2" >>"$RUNNER_TEMP/contributors" ;; + *) + echo "release-creator contributors: unrecognized arguments: $1" >&2 + exit 2 + ;; + esac + shift 2 + done exit 0 fi exec "$(dirname "$0")/../../.release-tools/bin/release-creator" "$@" From d532d9a04041d9ecddf9935ede806b632a96ac9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 14:05:13 -0700 Subject: [PATCH 30/35] =?UTF-8?q?=F0=9F=94=A7=20ci:=20admit=20release-crea?= =?UTF-8?q?tor=203.1.0=20in=20gw's=20own=20jobs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the temporary release-creator cutoff to 2026-10-25T21:03:31Z, one second after 3.1.0's last upload, in gw's [tool.nab] table and workflows. Delete it after 2026-11-02. --- .github/workflows/check.yaml | 4 ++-- .github/workflows/news.yaml | 8 ++++---- .github/workflows/prepare-release.yaml | 4 ++-- .github/workflows/release.yaml | 4 ++-- .github/workflows/update-tools.yaml | 4 ++-- pyproject.toml | 4 ++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 4b303b1..09c4ffd 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -102,8 +102,8 @@ jobs: - name: Install release tools env: EXCLUDE_NEWER: 7 days - # Temporary: release-creator 2.0.0 is younger than seven days. Delete after 2026-11-01. - EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-25T07:08:32Z + # Temporary: release-creator 3.1.0 is younger than seven days. Delete after 2026-11-02. + EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-25T21:03:31Z run: | tools/install_live_tools.sh .release-tools 'release-creator>=2.0.0' 'towncrier>=26.9' 'prek>=0.2' 'yq>=4.4.5' - name: Run Action tests diff --git a/.github/workflows/news.yaml b/.github/workflows/news.yaml index 8edd1f3..de828ea 100644 --- a/.github/workflows/news.yaml +++ b/.github/workflows/news.yaml @@ -22,12 +22,12 @@ jobs: - name: Install release tools env: EXCLUDE_NEWER: 7 days - # Temporary: release-creator 2.0.0 is younger than seven days. Delete after 2026-11-01. - EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-25T07:08:32Z + # Temporary: release-creator 3.1.0 is younger than seven days. Delete after 2026-11-02. + EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-25T21:03:31Z run: | tools/install_live_tools.sh "$RUNNER_TEMP/release-tools" 'release-creator>=2.0.0' 'towncrier>=26.9' uv run --no-project --python "$RUNNER_TEMP/release-tools" python tools/expose_locked_commands.py "$RUNNER_TEMP/release-tools" - uses: $/validate-news with: - # Temporary: release-creator 2.0.0 is younger than seven days. Delete after 2026-11-01. - exclude-newer-package: release-creator=2026-10-25T07:08:32Z + # Temporary: release-creator 3.1.0 is younger than seven days. Delete after 2026-11-02. + exclude-newer-package: release-creator=2026-10-25T21:03:31Z diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml index ddf85ce..fc61164 100644 --- a/.github/workflows/prepare-release.yaml +++ b/.github/workflows/prepare-release.yaml @@ -17,7 +17,7 @@ jobs: pull-requests: read with: bump: ${{ inputs.bump }} - # Temporary: release-creator 2.0.0 is younger than seven days. Delete after 2026-11-01. - exclude-newer-package: release-creator=2026-10-25T07:08:32Z + # Temporary: release-creator 3.1.0 is younger than seven days. Delete after 2026-11-02. + exclude-newer-package: release-creator=2026-10-25T21:03:31Z secrets: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3b11eba..fea566a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -48,8 +48,8 @@ jobs: - name: Install release tools env: EXCLUDE_NEWER: 7 days - # Temporary: release-creator 2.0.0 is younger than seven days. Delete after 2026-11-01. - EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-25T07:08:32Z + # Temporary: release-creator 3.1.0 is younger than seven days. Delete after 2026-11-02. + EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-25T21:03:31Z run: | tools/install_live_tools.sh .venv 'release-creator>=2.0.0' uv run --no-project --python .venv python tools/expose_locked_commands.py .venv diff --git a/.github/workflows/update-tools.yaml b/.github/workflows/update-tools.yaml index 0ae67d8..14a8daa 100644 --- a/.github/workflows/update-tools.yaml +++ b/.github/workflows/update-tools.yaml @@ -15,5 +15,5 @@ jobs: commit-message: "🔧 build: refresh release tool lock" pull-request-title: "🔧 build: refresh release tool lock" pull-request-body: "Refresh the locked tools used by release preparation and CI." - # Temporary: release-creator 2.0.0 is younger than seven days. Delete after 2026-11-01. - exclude-newer-package: release-creator=2026-10-25T07:08:32Z + # Temporary: release-creator 3.1.0 is younger than seven days. Delete after 2026-11-02. + exclude-newer-package: release-creator=2026-10-25T21:03:31Z diff --git a/pyproject.toml b/pyproject.toml index f091142..476de06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,9 +35,9 @@ uploaded-prior-to = "P7D" python = ">=3.15,<3.16" platforms = ["linux_x86_64"] -# Temporary: release-creator 2.0.0 is younger than seven days. Delete after 2026-11-01. +# Temporary: release-creator 3.1.0 is younger than seven days. Delete after 2026-11-02. [tool.nab.packages.release-creator] -uploaded-prior-to = 2026-10-25T07:08:32Z +uploaded-prior-to = 2026-10-25T21:03:31Z [tool.ruff] line-length = 120 From adbd2eed33716cb5ebcd6b84207545f086f003c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 14:05:41 -0700 Subject: [PATCH 31/35] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20build:=20require=20r?= =?UTF-8?q?elease-creator=203.1.0=20for=20gw's=20own=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raise the release-prepare, release-validate and test-tool floors to release-creator>=3.1.0, which adds the contributors command, and relock gw's pylock.toml with nab lock --all-groups --all-extras --upgrade. --- .github/workflows/check.yaml | 2 +- CONTRIBUTING.md | 2 +- pylock.toml | 28 ++++++++++++++-------------- pyproject.toml | 4 ++-- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 09c4ffd..7052637 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -105,7 +105,7 @@ jobs: # Temporary: release-creator 3.1.0 is younger than seven days. Delete after 2026-11-02. EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-25T21:03:31Z run: | - tools/install_live_tools.sh .release-tools 'release-creator>=2.0.0' 'towncrier>=26.9' 'prek>=0.2' 'yq>=4.4.5' + tools/install_live_tools.sh .release-tools 'release-creator>=3.1.0' 'towncrier>=26.9' 'prek>=0.2' 'yq>=4.4.5' - name: Run Action tests run: uvx --with tox-uv tox run -e test type: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eba1495..6a8b5a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ index: ```bash uv venv --allow-existing --python 3.15 .venv EXCLUDE_NEWER="7 days" tools/install_live_tools.sh .release-tools \ - 'release-creator>=2.0.0' 'towncrier>=26.9' 'prek>=0.2' 'yq>=4.4.5' + 'release-creator>=3.1.0' 'towncrier>=26.9' 'prek>=0.2' 'yq>=4.4.5' uv pip install --python .venv/bin/python --group test export PATH="$PWD/.release-tools/bin:$PWD/.venv/bin:$PATH" ``` diff --git a/pylock.toml b/pylock.toml index e86cb72..e8db8ca 100644 --- a/pylock.toml +++ b/pylock.toml @@ -1046,7 +1046,7 @@ sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" [[packages]] name = "release-creator" -version = "2.0.0" +version = "3.1.0" marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" requires-python = ">=3.15" dependencies = [ @@ -1056,28 +1056,27 @@ dependencies = [ { name = "mdformat-gfm" }, { name = "packaging" }, { name = "pyyaml" }, - { name = "tomli-w" }, { name = "towncrier" }, ] index = "https://pypi.org/simple/" [packages.sdist] -name = "release_creator-2.0.0.tar.gz" -upload-time = 2026-10-25 07:08:31.274557+00:00 -url = "https://files.pythonhosted.org/packages/5d/40/55f7a22a3cda929f9efdf09dbb825e888487e658ce14892f2340218335c1/release_creator-2.0.0.tar.gz" -size = 209257 +name = "release_creator-3.1.0.tar.gz" +upload-time = 2026-10-25 21:03:30.896511+00:00 +url = "https://files.pythonhosted.org/packages/03/98/73278ccb2393b27b702100a0eff5b387f2db6f06fc8aa0dea60bbfcbd402/release_creator-3.1.0.tar.gz" +size = 178357 [packages.sdist.hashes] -sha256 = "f7ccec9787327a7e12f4a79c15dff470e894d3a88c9bb60eac9c6f1984ee63bf" +sha256 = "7fba39aa52283c7edb4bf906656296a0696b4d23f6d6df85ef5d9a7abca7e1b2" [[packages.wheels]] -name = "release_creator-2.0.0-py3-none-any.whl" -upload-time = 2026-10-25 07:08:29.746110+00:00 -url = "https://files.pythonhosted.org/packages/88/f1/d7103a5c410019207015fc8229f976e8906da90409eddd5e00066bb360ca/release_creator-2.0.0-py3-none-any.whl" -size = 66805 +name = "release_creator-3.1.0-py3-none-any.whl" +upload-time = 2026-10-25 21:03:29.549360+00:00 +url = "https://files.pythonhosted.org/packages/35/f2/20dca23b89aadcd846a7b36d763440a2e5cf2ef3b2adda350af16406aa52/release_creator-3.1.0-py3-none-any.whl" +size = 51886 [packages.wheels.hashes] -sha256 = "cf93c2395f4a9ba80bd503792c16189fc238c5d82bb90535250de2968d8d2e92" +sha256 = "e378a399af800452180ca1748dfb981b9f0115b634587226be927162b4b34f17" [[packages]] name = "setuptools" @@ -1107,7 +1106,7 @@ sha256 = "51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670" [[packages]] name = "tomli-w" version = "1.2.0" -marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"test\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" @@ -1468,13 +1467,14 @@ sha256 = "6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab" [tool.nab] nab-version = "0.0.22" -created-at = 2026-10-25 20:50:04.373872+00:00 +created-at = 2026-10-25 21:05:19.966222+00:00 command-line = [ "nab", "lock", "--all-groups", "--all-extras", "--no-cache", + "--upgrade", ] input-path = "pyproject.toml" mode = "universal" diff --git a/pyproject.toml b/pyproject.toml index 476de06..00dd120 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,8 @@ license-files = [ "LICENSE" ] [dependency-groups] build = [ "Cython>=3.0", "setuptools>=80" ] -release-prepare = [ "release-creator>=2.0.0", "towncrier>=26.9", "prek>=0.2" ] -release-validate = [ "release-creator>=2.0.0", "prek>=0.2" ] +release-prepare = [ "release-creator>=3.1.0", "towncrier>=26.9", "prek>=0.2" ] +release-validate = [ "release-creator>=3.1.0", "prek>=0.2" ] test = [ "certifi", "coverage[toml]>=7.10", From 140a855388c968faa0d99983399aa06200334afb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 14:08:52 -0700 Subject: [PATCH 32/35] =?UTF-8?q?=F0=9F=90=9B=20fix(prepare):=20pass=20the?= =?UTF-8?q?=20changelog=20version=20to=20contributors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the changelog heading's version, with changelog-prefix and without the tag's v, so a v-prefixed tag finds its section. --- prepare-towncrier/action.yml | 4 +++- tests/release_creator_shim/release-creator | 18 ------------------ 2 files changed, 3 insertions(+), 19 deletions(-) delete mode 100755 tests/release_creator_shim/release-creator diff --git a/prepare-towncrier/action.yml b/prepare-towncrier/action.yml index 1347e99..d109132 100644 --- a/prepare-towncrier/action.yml +++ b/prepare-towncrier/action.yml @@ -98,10 +98,12 @@ runs: shell: bash env: VERSION: ${{ steps.version.outputs.version }} + CHANGELOG_PREFIX: ${{ inputs.changelog-prefix }} CHANGELOG: ${{ steps.config.outputs.changelog }} REPOSITORY: ${{ github.repository }} GH_TOKEN: ${{ github.token }} - run: release-creator contributors --changelog "$CHANGELOG" --version "$VERSION" --repository "$REPOSITORY" + run: >- + release-creator contributors --changelog "$CHANGELOG" --version "$CHANGELOG_PREFIX${VERSION#v}" --repository "$REPOSITORY" - name: Reject other hook configurations if: inputs.phase != 'build' shell: bash diff --git a/tests/release_creator_shim/release-creator b/tests/release_creator_shim/release-creator deleted file mode 100755 index 123900d..0000000 --- a/tests/release_creator_shim/release-creator +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# release-creator 3.1.0 adds contributors; until it is on PyPI, the tests record the call and run the rest for real. -set -euo pipefail -if [[ "$1" == contributors ]]; then - shift - while (($#)); do - case "$1" in - --changelog | --version | --repository) printf '%s\n' "$1" "$2" >>"$RUNNER_TEMP/contributors" ;; - *) - echo "release-creator contributors: unrecognized arguments: $1" >&2 - exit 2 - ;; - esac - shift 2 - done - exit 0 -fi -exec "$(dirname "$0")/../../.release-tools/bin/release-creator" "$@" From 5c7c2dc318efdf8bef4d34965b41517010d566da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 14:08:52 -0700 Subject: [PATCH 33/35] =?UTF-8?q?=E2=9C=85=20test:=20use=20the=20real=20re?= =?UTF-8?q?lease-creator=20contributors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the release-creator shim now that 3.1.0 is on PyPI. The fake GitHub GraphQL endpoint now answers 3.1.0's merge commit author, pull request and first-contribution search queries. --- README.md | 2 +- tests/conftest.py | 69 +++++++++++++++---------- tests/prepare_towncrier/test_prepare.py | 25 +++++---- 3 files changed, 59 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index a4e0f4c..8bf6a02 100644 --- a/README.md +++ b/README.md @@ -407,7 +407,7 @@ formatting must all succeed. After the Towncrier build, the Action runs release-creator's step that adds a "New Contributors" subsection to the new changelog section with `release-creator contributors`, which release-creator 3.1.0 adds. The caller's `release-prepare` -group needs that version once it is published. +group needs release-creator 3.1.0 or later. ```yaml - id: prepare diff --git a/tests/conftest.py b/tests/conftest.py index 3addda3..81554d3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import json import os +import re import shutil import ssl import subprocess @@ -134,35 +135,52 @@ def repository(tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]], mo for name, value in environment.items(): monkeypatch.setenv(name, value) - def associated_pulls(request: Request) -> Response: + author = {"login": "gaborbernat", "__typename": "User"} + + def graphql(request: Request) -> Response: query = request.get_json()["query"] - commits = (part.split('"', 1)[0] for part in query.split('object(oid: "')[1:]) - return Response( - json.dumps( - { - "data": { - "repository": { - f"c{index}": { - "associatedPullRequests": { - "nodes": [ - { - "number": int(environment.get("TEST_PULL_NUMBER", "2")), - "mergedAt": "2026-10-22T00:00:00Z", - "mergeCommit": {"oid": commit}, - "author": {"login": "gaborbernat", "__typename": "User"}, - } - ] + if 'object(oid: "' in query: + commits = (part.split('"', 1)[0] for part in query.split('object(oid: "')[1:]) + data: dict[str, object] = { + "repository": { + f"c{index}": { + "associatedPullRequests": { + "nodes": [ + { + "number": int(environment.get("TEST_PULL_NUMBER", "2")), + "mergedAt": "2026-10-22T00:00:00Z", + "mergeCommit": { + "oid": commit, + "authors": { + "nodes": [ + {"name": "Release test", "email": "a@example.org", "user": author} + ] + }, + }, + "author": author, } - } - for index, commit in enumerate(commits) + ] } } + for index, commit in enumerate(commits) } - ), - mimetype="application/json", - ) + } + elif "pullRequest(number: " in query: + numbers = re.findall(r"p(\d+): pullRequest", query) + data = { + "repository": { + f"p{number}": {"number": int(number), "mergedAt": "2026-10-22T00:00:00Z", "author": author} + for number in numbers + } + } + else: + data = { + alias: {"issueCount": int(environment.get("TEST_PRIOR_PULLS", "0"))} + for alias in re.findall(r"(s\d+): search", query) + } + return Response(json.dumps({"data": data}), mimetype="application/json") - server.expect_request("/api/graphql", method="POST").respond_with_handler(associated_pulls) + server.expect_request("/api/graphql", method="POST").respond_with_handler(graphql) return worktree @@ -377,10 +395,7 @@ def run(directory: Path, *command: str) -> subprocess.CompletedProcess[str]: def _tools_path(environment: dict[str, str]) -> str: - tools = Path(__file__).resolve().parents[1] / ".release-tools/bin" - return ( - f"{Path(__file__).parent / 'release_creator_shim'}:{tools}:{Path(sys.executable).parent}:{environment['PATH']}" - ) + return f"{Path(__file__).resolve().parents[1] / '.release-tools/bin'}:{Path(sys.executable).parent}:{environment['PATH']}" def _condition( diff --git a/tests/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py index 4dcc082..42b5304 100644 --- a/tests/prepare_towncrier/test_prepare.py +++ b/tests/prepare_towncrier/test_prepare.py @@ -105,6 +105,7 @@ def test_prepare_custom_fragment_directory( def test_prepare_renders_github_notes( repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] ) -> None: + github_api[1]["TEST_PRIOR_PULLS"] = "1" result = action( repository, tmp_path, @@ -212,6 +213,7 @@ def test_prepare_rejects_reused_version(repository: Path, tmp_path: Path, action def test_prepare_links_named_fragments( repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] ) -> None: + github_api[1]["TEST_PRIOR_PULLS"] = "1" result = action( repository, tmp_path, @@ -408,15 +410,20 @@ def test_prepare_rejects_other_hook_configuration( assert f"only in the root .pre-commit-config.yaml: {path}" in result.stderr -def test_prepare_adds_new_contributors_after_building(repository: Path, tmp_path: Path, action_scripts: Path) -> None: +@pytest.mark.parametrize( + ("prior_pulls", "credited"), [pytest.param("0", True, id="first-pull"), pytest.param("1", False, id="returning")] +) +def test_prepare_adds_new_contributors_after_building( + repository: Path, + tmp_path: Path, + action_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + prior_pulls: str, + credited: bool, +) -> None: + github_api[1]["TEST_PRIOR_PULLS"] = prior_pulls + result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="build") assert result.returncode == 0, result.stderr - assert (tmp_path / "contributors").read_text().splitlines() == [ - "--changelog", - "CHANGELOG.md", - "--version", - "0.2.0", - "--repository", - "tox-dev/fixture", - ] + assert ("### New Contributors" in (repository / "CHANGELOG.md").read_text()) is credited From 26b0b25495b5050c54d50bbff23c95a741746e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 14:10:47 -0700 Subject: [PATCH 34/35] =?UTF-8?q?=F0=9F=A7=B9=20chore:=20drop=20leftovers?= =?UTF-8?q?=20of=20removed=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove conftest input defaults no Action reads, the stale release-creator lock table in the caller fixture, and the shared-lock wording in the export-build-constraints lock input. --- export-build-constraints/action.yml | 2 +- tests/conftest.py | 5 ----- tests/fixtures/caller/pyproject.toml | 10 ---------- 3 files changed, 1 insertion(+), 16 deletions(-) diff --git a/export-build-constraints/action.yml b/export-build-constraints/action.yml index caad7df..174b59e 100644 --- a/export-build-constraints/action.yml +++ b/export-build-constraints/action.yml @@ -2,7 +2,7 @@ name: Export build constraints description: Write hashed build dependencies selected from the caller's pylock inputs: lock: - description: pylock.toml merges shared and caller pins; another lock uses only its build pins + description: Lock whose build group supplies the constraints, such as pylock.build.toml default: pylock.toml target-platform: description: Nab platform ID such as linux_aarch64; required with targets diff --git a/tests/conftest.py b/tests/conftest.py index 81554d3..2a315b0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -260,19 +260,14 @@ def action( "CHANGELOG": "CHANGELOG.md", "FRAGMENTS": "changelog.d", "FORMAT": action_config.get("inputs", {}).get("format", {}).get("default", "none"), - "EXPECTED_SHA": head, "CHECKS": "true", "BRANCH": "main", - "REMOTE": "origin", "ANNOTATED": "false", "ARTIFACT_URL": "", "ARTIFACT_LABEL": "PyPI release", "LOCK": "pylock.toml", "MODE": "complete", - "RUN_ID": "42", - "WORKFLOW_PATH": ".github/workflows/check.yaml", } | extra_env - input_values["REPOSITORY"] = extra_env.get("REPOSITORY", "tox-dev/fixture") inputs = _inputs(action_config, input_values) input_names = {input_name.replace("-", "_").upper() for input_name in action_config.get("inputs", {})} environment: dict[str, str] = ( diff --git a/tests/fixtures/caller/pyproject.toml b/tests/fixtures/caller/pyproject.toml index 479adbd..68b57a3 100644 --- a/tests/fixtures/caller/pyproject.toml +++ b/tests/fixtures/caller/pyproject.toml @@ -40,16 +40,6 @@ test = [ "ty", ] -[tool.release-creator.locks.exclude-newer-package] -nab = "2026-10-18T05:45:09Z" -nab-index = "2026-10-18T05:45:09Z" -nab-markersets = "2026-10-18T05:45:09Z" -nab-project = "2026-10-18T05:45:09Z" -nab-provider = "2026-10-18T05:45:09Z" -nab-resolver = "2026-10-18T05:45:09Z" -release-creator = "2026-10-25T07:08:32Z" -uv = "2026-10-22T01:32:34Z" - [tool.ruff] line-length = 120 From 2252e6ae602e72359e8eaf38f7a4c16f48038a72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 25 Oct 2026 14:58:56 -0700 Subject: [PATCH 35/35] =?UTF-8?q?=F0=9F=A7=AA=20test:=20find=20prek=20in?= =?UTF-8?q?=20the=20release=20tool=20venv?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/release_workflow/test_graph.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index 2b26e66..9c26555 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -95,7 +95,8 @@ def test_shfmt_hook_rejects_unformatted_inline_shell(tmp_path: Path) -> None: subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) script = tmp_path / "inline-shell-check.sh" script.write_text("#!/usr/bin/env bash\nif true\nthen\necho x\nfi\n") - executable = shutil.which("prek") + tools = Path(__file__).resolve().parents[2] / ".release-tools/bin" + executable = shutil.which("prek", path=f"{tools}{os.pathsep}{os.environ['PATH']}") assert executable is not None result = subprocess.run( [