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..7052637 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,14 @@ 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 + # 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>=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: @@ -121,37 +122,15 @@ 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 - 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/news.yaml b/.github/workflows/news.yaml index f42c886..de828ea 100644 --- a/.github/workflows/news.yaml +++ b/.github/workflows/news.yaml @@ -18,11 +18,16 @@ 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 + # 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 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 88ba696..fc61164 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 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/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 3cd3030..47c8589 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -14,14 +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 - 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 @@ -54,6 +46,18 @@ 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 + 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 @@ -63,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 @@ -86,36 +85,47 @@ 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 }} 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 + 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: - groups: release-prepare${{ inputs.local-tool-source && ' project-runtime' || '' }} + version: latest-known + python-version: "3.15" + enable-cache: false + - uses: $/install-locked-tools + with: + groups: release-prepare venv: .venv - local-tool-source: ${{ inputs.local-tool-source }} - - uses: $/verify-frozen-hooks + exclude-newer: ${{ inputs.exclude-newer }} + 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 - background: true - - uses: $/install-local-release-creator - if: inputs.local-tool-source + uses: $/cooled-prek with: - build-constraints: ${{ steps.tools.outputs.build-constraints }} + args: install-hooks + background: true - id: prepare name: Build changelog uses: $/prepare-towncrier @@ -152,16 +162,12 @@ 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 }} + 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/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/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8ad693f..fea566a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -8,82 +8,28 @@ 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: 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 - - uses: $/install-locked-tools - with: - groups: release-notes - venv: .venv - - name: Verify tagged source + - name: Verify the exact release tag env: TAG: ${{ github.ref_name }} run: | set -euo pipefail - test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$(git rev-parse HEAD)" + 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 - - 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 @@ -92,27 +38,25 @@ 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: version: latest-known python-version: "3.15" enable-cache: false - - uses: $/install-locked-tools - with: - groups: release-notes - venv: .venv - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - artifact-ids: ${{ needs.contributors.outputs.artifact-id }} - path: ${{ runner.temp }} + - name: Install release tools + env: + EXCLUDE_NEWER: 7 days + # 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 - 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: @@ -120,7 +64,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: @@ -130,6 +74,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/update-tools.yaml b/.github/workflows/update-tools.yaml index 313dce5..14a8daa 100644 --- a/.github/workflows/update-tools.yaml +++ b/.github/workflows/update-tools.yaml @@ -15,3 +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 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/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index 114db8c..84a172b 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 @@ -42,7 +38,19 @@ 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 + default: "" + 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 @@ -75,7 +83,9 @@ 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 }} + nab-git-commit: ${{ inputs.nab-git-commit }} + 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' @@ -84,60 +94,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 +108,12 @@ 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 +121,18 @@ 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" != "$ADDITIONAL_LOCK" ]]; 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 @@ -209,7 +144,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..662367a 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: "" @@ -42,19 +38,28 @@ 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 + 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: - 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 @@ -62,45 +67,19 @@ jobs: version: latest-known python-version: "3.15" enable-cache: false - - uses: $/check-tagged-source + - uses: $/refresh-release-lock with: - keep-uv-lock: ${{ inputs.keep-uv-lock }} + mode: check additional-lock: ${{ inputs.additional-lock }} additional-lock-groups: ${{ inputs.additional-lock-groups }} additional-lock-selection: ${{ inputs.additional-lock-selection }} additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} - 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 - - 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 + nab-git-commit: ${{ inputs.nab-git-commit }} + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} build: - needs: [source-check, constraints] runs-on: ubuntu-26.04 outputs: artifact-id: ${{ steps.upload.outputs.artifact-id }} @@ -119,17 +98,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 @@ -164,30 +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" '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/* - - name: Check wheel contents - run: uv run --no-project --python "$RUNNER_TEMP/package-validators" check-wheel-contents --no-config dist diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9871625..6a8b5a6 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>=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/.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 5e15200..8bf6a02 100644 --- a/README.md +++ b/README.md @@ -6,39 +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. - -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. +`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. + +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. - `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. -- `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 - without installing them. Use this before bundling packages from a separate runtime lock, such as a zipapp. +- `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 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. +- `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: @@ -54,6 +52,34 @@ 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. 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 + 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`. + +`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 `validate-news` reads `tool.towncrier` from `pyproject.toml`. It checks every added or modified fragment in the @@ -90,19 +116,15 @@ 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 +## Refresh release tools -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. +Call `upgrade-release-tools.yaml` from a scheduled or manual workflow. Its read-only job refreshes the universal +`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 @@ -127,114 +149,39 @@ 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`, `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. 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. - -## 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. - -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. - -`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. - -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 }} -``` +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 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 -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` 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: @@ -249,38 +196,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. - -```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 @@ -313,41 +228,89 @@ 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. +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. 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: 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 `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. +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. +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`, +and then creates the GitHub Release with a single `publish-github-release` call after PyPI, with no separate stage job: + +```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 @@ -382,11 +345,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: @@ -413,11 +376,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 @@ -444,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 with `release-creator contributors`, which release-creator 3.1.0 adds. The caller's `release-prepare` +group needs release-creator 3.1.0 or later. + ```yaml - id: prepare uses: tox-dev/github-workflows/prepare-towncrier@SHA @@ -531,7 +496,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 @@ -540,7 +505,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. @@ -549,18 +513,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 @@ -576,14 +536,13 @@ 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 `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. @@ -595,9 +554,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/+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/changelog.d/+cutoff-table.breaking.md b/changelog.d/+cutoff-table.breaking.md new file mode 100644 index 0000000..d717aa7 --- /dev/null +++ b/changelog.d/+cutoff-table.breaking.md @@ -0,0 +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]`, and gw neither checks nor expires them. 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/+frozen-hooks.breaking.md b/changelog.d/+frozen-hooks.breaking.md new file mode 100644 index 0000000..43a8222 --- /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/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/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/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/+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/+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/changelog.d/+mise-pin.breaking.md b/changelog.d/+mise-pin.breaking.md new file mode 100644 index 0000000..25b64c6 --- /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/changelog.d/+nab-git-commit.feature.md b/changelog.d/+nab-git-commit.feature.md new file mode 100644 index 0000000..900249c --- /dev/null +++ b/changelog.d/+nab-git-commit.feature.md @@ -0,0 +1,3 @@ +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/changelog.d/+nab-primary-lock.breaking.md b/changelog.d/+nab-primary-lock.breaking.md new file mode 100644 index 0000000..0c9e4e6 --- /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/+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/changelog.d/+new-contributors.feature.md b/changelog.d/+new-contributors.feature.md new file mode 100644 index 0000000..6111ff0 --- /dev/null +++ b/changelog.d/+new-contributors.feature.md @@ -0,0 +1,2 @@ +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/changelog.d/+own-prepare-lock.packaging.md b/changelog.d/+own-prepare-lock.packaging.md new file mode 100644 index 0000000..e701c59 --- /dev/null +++ b/changelog.d/+own-prepare-lock.packaging.md @@ -0,0 +1,3 @@ +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/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/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 new file mode 100644 index 0000000..040d89a --- /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 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/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/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/changelog.d/+tag-release-jobs.breaking.md b/changelog.d/+tag-release-jobs.breaking.md new file mode 100644 index 0000000..ae28532 --- /dev/null +++ b/changelog.d/+tag-release-jobs.breaking.md @@ -0,0 +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 `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/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/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." 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/changelog.d/+writer-allowlist.breaking.md b/changelog.d/+writer-allowlist.breaking.md new file mode 100644 index 0000000..edaf11e --- /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-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/check-tagged-source/action.yml b/check-tagged-source/action.yml index 60a40a5..234505d 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, 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 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: "" @@ -22,6 +19,15 @@ 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 + exclude-newer-package: + description: Newline-separated name=date cutoffs that override exclude-newer for single packages + default: "" runs: using: composite steps: @@ -33,23 +39,18 @@ 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: 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 }} 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 - - uses: $/verify-frozen-hooks - - name: Run tagged source hooks - shell: bash - run: '"$GITHUB_ACTION_PATH/../tools/cooled_prek.sh" run --all-files' + nab-git-commit: ${{ inputs.nab-git-commit }} + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} 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/export-build-constraints/action.yml b/export-build-constraints/action.yml index 1304ebb..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 @@ -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-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..892d808 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -7,12 +7,6 @@ inputs: venv: description: Virtual environment directory default: "" - 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: "" @@ -28,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 @@ -40,35 +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" --only-binary :all: '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 }} - LOCAL_TOOL_SOURCE: ${{ inputs.local-tool-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" @@ -85,16 +70,14 @@ 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 - source_check=() - if [[ "$LOCAL_TOOL_SOURCE" == true ]]; then source_check=(--local-tool-source); fi + # 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 \ - "${source_check[@]}" + "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" pylock.toml "${selection[@]}" + 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,50 +96,39 @@ 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 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 }} TARGETS: ${{ inputs.targets }} 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 + # 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 \ - "${lock_check[@]}" + "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$CONSTRAINTS_LOCK" --build-constraints 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/prepare-towncrier/action.yml b/prepare-towncrier/action.yml index 82edc19..d109132 100644 --- a/prepare-towncrier/action.yml +++ b/prepare-towncrier/action.yml @@ -93,6 +93,28 @@ 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_PREFIX: ${{ inputs.changelog-prefix }} + CHANGELOG: ${{ steps.config.outputs.changelog }} + REPOSITORY: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + run: >- + release-creator contributors --changelog "$CHANGELOG" --version "$CHANGELOG_PREFIX${VERSION#v}" --repository "$REPOSITORY" + - 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/pylock.toml b/pylock.toml index 7d05603..e8db8ca 100644 --- a/pylock.toml +++ b/pylock.toml @@ -1,4584 +1,1403 @@ lock-version = "1.0" -created-by = "release-creator (uv)" +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" -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 = [] +created-by = "nab" [[packages]] -name = "annotated-types" -version = "0.8.0" -marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +name = "cachetools" +version = "7.2.3" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.10" 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 +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 = "13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7" +sha256 = "e6fafd06de4a5bd915ff7d247588ea497e28c9a568c62db8678d9a5f11515a9a" [[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 +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 = "f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0" +sha256 = "85ef9d2703918bedf7f88caa0b06bb3bb7a71360eb0b05c20796b89fede6e30c" [[packages]] -name = "argcomplete" -version = "3.7.2" -marker = "\"release-hook-check\" in dependency_groups or \"release-shell\" in dependency_groups" +name = "certifi" +version = "2026.10.18" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.7" 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 +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 = "aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9" +sha256 = "b9995d97b64d0431a5af837d8ca8cd6238104d1f319f04bd93ea43124d6c861e" [[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 +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 = "6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19" +sha256 = "73191c47aaa79da394e8fee2dbc2ec827432cab56311122c9e47abf79e390f62" [[packages]] -name = "attrs" -version = "26.1.0" +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] -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 +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 = "d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32" +sha256 = "dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be" [[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 +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 = "c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309" +sha256 = "aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399" [[packages]] -name = "build" -version = "1.6.1" -marker = "\"lock-tools\" in dependency_groups" +name = "click" +version = "8.5.0" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.10" 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 +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 = "51cc11666391ab6f092070437ac747002ff46f3e4113a3622177ee6b488bfc53" +sha256 = "ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34" [[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 +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 = "ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7" +sha256 = "255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360" [[packages]] -name = "cachetools" -version = "7.2.3" +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] -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 +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 = "e6fafd06de4a5bd915ff7d247588ea497e28c9a568c62db8678d9a5f11515a9a" +sha256 = "08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" [[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 +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 = "85ef9d2703918bedf7f88caa0b06bb3bb7a71360eb0b05c20796b89fede6e30c" +sha256 = "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" [[packages]] -name = "certifi" -version = "2026.7.22" -marker = "\"lock-reader\" in dependency_groups or \"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +name = "coverage" +version = "7.16.2" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.10" 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 +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 = "741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55" +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]] -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 +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 = "62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775" +sha256 = "11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f" [[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'))" +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] -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 +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 = "dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be" +sha256 = "7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5" [[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 +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 = "dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d" +sha256 = "630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2" [[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 +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 = "da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b" +sha256 = "4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856" [[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 +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 = "fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4" +sha256 = "9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079" [[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 +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 = "2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8" +sha256 = "f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67" [[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 +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 = "507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6" +sha256 = "58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7" [[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 +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 = "246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80" +sha256 = "e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c" [[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 +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 = "471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779" +sha256 = "ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767" [[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 +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 = "aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399" +sha256 = "f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5" [[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 +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 = "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 +sha256 = "1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c" -[packages.wheels.hashes] -sha256 = "cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7" +[[packages]] +name = "cython" +version = "3.3.0" +marker = "\"build\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" -[[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.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.wheels.hashes] -sha256 = "4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac" +[packages.sdist.hashes] +sha256 = "eed0d93fbca7087f143b42c34b05a825849bdf17f101572c2105acfa49aa88b8" [[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 +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 = "e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960" +sha256 = "b447f6906e0555f05dc4742ef1f99091b1e5d9aa9f16616e772fbf9ff6271616" [[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 +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 = "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 +sha256 = "9b24b5c8cd536946b62086fcafee6d5509d3f549f72d553d2336af87ffbe0da1" -[packages.wheels.hashes] -sha256 = "c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc" +[[packages]] +name = "distlib" +version = "0.4.4" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" -[[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.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.wheels.hashes] -sha256 = "4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab" +[packages.sdist.hashes] +sha256 = "ca4175b4ac7f71223cc30ed7248c45d2b38c2439d8ee6e498ab67ed63cf3508d" [[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 +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 = "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 +sha256 = "cf09d55b1e85a89576b036aeb71550bfd68320f3f21761ca329eadadad918638" -[packages.wheels.hashes] -sha256 = "df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358" +[[packages]] +name = "docutils" +version = "0.23" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" -[[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.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.wheels.hashes] -sha256 = "a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231" +[packages.sdist.hashes] +sha256 = "746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e" [[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 +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 = "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 +sha256 = "25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea" -[packages.wheels.hashes] -sha256 = "770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94" +[[packages]] +name = "execnet" +version = "2.1.2" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" -[[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.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.wheels.hashes] -sha256 = "7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5" +[packages.sdist.hashes] +sha256 = "63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd" [[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 +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 = "06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66" +sha256 = "67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec" -[[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]] +name = "filelock" +version = "4.2.2" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.11" +index = "https://pypi.org/simple/" -[packages.wheels.hashes] -sha256 = "d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3" +[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]] -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 +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 = "d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692" +sha256 = "1851d2f14ca2901e98069d68333b33ad7409f56904e17b48cc0c32a0db8ed73a" [[packages]] -name = "charset-normalizer" -version = "3.5.2" -marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +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] -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 +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 = "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" +sha256 = "c4468f73144c054d2aab4ef0f0378c43b9878bf07f8ffd6b79690e970d375f07" [[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 +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 = "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 +sha256 = "08ecf7548fb48205e7f213d70c71e67b8271b7242093dc3f1da578b42c734a2c" -[packages.wheels.hashes] -sha256 = "498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253" +[[packages]] +name = "idna" +version = "3.20" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" -[[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.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.wheels.hashes] -sha256 = "e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0" +[packages.sdist.hashes] +sha256 = "a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44" [[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 +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 = "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 +sha256 = "ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c" -[packages.wheels.hashes] -sha256 = "3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229" +[[packages]] +name = "iniconfig" +version = "2.3.1" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" -[[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.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.wheels.hashes] -sha256 = "04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5" +[packages.sdist.hashes] +sha256 = "67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960" [[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 +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 = "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 +sha256 = "9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7" -[packages.wheels.hashes] -sha256 = "16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3" +[[packages]] +name = "jinja2" +version = "3.1.6" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.7" +dependencies = [ + { name = "markupsafe" }, +] +index = "https://pypi.org/simple/" -[[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.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.wheels.hashes] -sha256 = "7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2" +[packages.sdist.hashes] +sha256 = "0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" [[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 +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 = "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" +sha256 = "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" [[packages]] -name = "sigstore" -version = "4.5.0" -marker = "\"release-attestations\" in dependency_groups" +name = "markdown-it-py" +version = "4.2.0" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "mdurl" }, +] 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 +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 = "020d3e07f622b2916bf453e66ff6ff0711e1fdc5ab69e8bd8902f71d9fcb316f" +sha256 = "04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49" [[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 +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 = "f045b207f2e12605cf775ec38e89c5eda625d71ffa7830477db65e47ec2bc8b2" +sha256 = "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" [[packages]] -name = "sigstore-models" -version = "0.0.6" -marker = "\"release-attestations\" in dependency_groups" +name = "markupsafe" +version = "3.0.4" +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/" [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 +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 = "c766c09470c2a7e8a4a333c893f07e2001c56a3ff1757b1a246119f53169a849" +sha256 = "2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6" [[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 +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 = "5201a68f4d7d0f8bec1e2f4378eb646b084c52609a4e31db8c385095fff68b2e" +sha256 = "925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369" [[packages]] -name = "sigstore-rekor-types" -version = "0.0.18" -marker = "\"release-attestations\" in dependency_groups" +name = "mdformat" +version = "1.0.0" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "markdown-it-py" }, +] 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 +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 = "19aef25433218ebf9975a1e8b523cc84aaf3cd395ad39a30523b083ea7917ec5" +sha256 = "4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928" [[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 +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 = "b62bf38c5b1a62bc0d7fe0ee51a0709e49311d137c7880c329882a8f4b2d1d78" +sha256 = "bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b" [[packages]] -name = "tomli" -version = "2.5.0" -marker = "\"lock-tools\" in dependency_groups" +name = "mdformat-gfm" +version = "1.0.0" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" 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] -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 +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 = "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" +sha256 = "d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623" [[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 +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 = "32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b" +sha256 = "7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005" [[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" +name = "mdit-py-plugins" +version = "0.6.1" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "markdown-it-py" }, +] 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 +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 = "2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021" +sha256 = "a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0" [[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 +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 = "188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90" +sha256 = "214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d" [[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" +name = "mdurl" +version = "0.1.2" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.7" 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 +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 = "83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6" +sha256 = "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" [[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 +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 = "05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437" +sha256 = "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" [[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" +name = "packaging" +version = "26.3" +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/" [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 +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 = "ace9031631c718cc0709107b8755e6e31350eb8acdc877af43df3177addb9a55" +sha256 = "94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79" [[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 +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 = "ae4d223e6aadaff98d29b7f09e58d9c9ccf4cd3b455a2d2e0486d432cd8bf660" +sha256 = "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" [[packages]] -name = "tox" -version = "4.67.0" +name = "pathspec" +version = "1.1.1" marker = "\"test\" in dependency_groups" +requires-python = ">=3.9" 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 +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 = "d3e48816bebbd7bad0f6942a36fa7bcd14fe4ccbf4b3a5d54040ca0294dafd1f" +sha256 = "17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a" [[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 +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 = "8fbe14416ba59543d3df3b9376537e5132fd2dd44176591285ecefb40b179533" +sha256 = "a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189" [[packages]] -name = "tox-uv" -version = "1.37.0" +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]] -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 +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 = "f2e7de8f12d7ffd850d2a3dcebf278cf72a8952a9abe75d84cfb54a76ee17ae4" +sha256 = "502ce08e9d36cf90579ee44e1e764838842fa658e4975ef47344fd053011d2c8" [[packages]] -name = "tox-uv-bare" -version = "1.37.0" +name = "pluggy" +version = "1.7.0" marker = "\"test\" in dependency_groups" +requires-python = ">=3.10" 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 +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 = "97418f2febec7da57d010ca6ffed8c7d56be9d425f381b74c7fe8d0213fccad9" +sha256 = "d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8" [[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 +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 = "a596c0726690e2182cc85dfbc2f39e6fddacb8f7ea08bf8bace122a17397cd13" +sha256 = "7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec" [[packages]] -name = "trove-classifiers" -version = "2026.9.21.13" -marker = "\"test\" in dependency_groups" +name = "prek" +version = "0.5.5" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.8" 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 +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 = "0a9ebc8d4e2f3e8a22848c5258033035bec17a3012ac3fea16dbaa764489eb71" +sha256 = "16bf596768bb754779c7d42317e9ca16ac609c0cce7d13bae0829944ab40a26a" [[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 +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 = "8b1ff4f9c191b1040b71c37f1e445ab99732911e3cd91de52838453a854d7a17" +sha256 = "73d509c0d9556171de2502bada4962be7a25871e7b23e782549074e1416dc850" [[packages]] -name = "trustme" -version = "1.2.1" +name = "pycparser" +version = "3.11" marker = "\"test\" in dependency_groups" +requires-python = ">=3.10" 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 +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 = "6528ba2bbc7f2db41f33825c8dd13e3e3eb9d334ba0f909713c8c3139f4ae47f" +sha256 = "d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc" [[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 +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 = "d768e5fc57c86dfc5ec9365102e9b092541cd6954b35d8c1eea01a84f35a762a" +sha256 = "51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80" [[packages]] -name = "truststore" -version = "0.10.5" -marker = "\"lock-tools\" in dependency_groups" +name = "pygments" +version = "2.21.0" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.9" 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 +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 = "30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd" +sha256 = "610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c" [[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 +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 = "9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c" +sha256 = "2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9" [[packages]] -name = "tuf" -version = "7.0.1" -marker = "\"release-attestations\" in dependency_groups" +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] -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 +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 = "5ada1db78da3518fa851e28588f4b2249b8662a28ae528f54afa2a1de541fb31" +sha256 = "2e83226079dc446a8e2357def7622c01c733284e39afc8e332d9f722fe0c2beb" [[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 +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 = "d30434bda6e079ab303fb30d1b3006d939a10ca34783b1573d61cd9b802fa45c" +sha256 = "1d3cbf37ac4c3a40940e09cbd4374608d25cdd89e6019f7e06fc285afd3ab467" [[packages]] -name = "twine" -version = "7.0.0" +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] -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 +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 = "85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177" +sha256 = "1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" [[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 +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 = "b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7" +sha256 = "37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" [[packages]] -name = "ty" -version = "0.0.89" +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] -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 +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 = "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" +sha256 = "d07744ce2efaafa0575ed3081d416b999c773b09e84aa95065138bedc3e12352" [[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 +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 = "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 +sha256 = "749937892dbf909c8b703503b491be66fc24073e7988bf9b4d783af4f33da942" -[packages.wheels.hashes] -sha256 = "3dd4c57ba58bab8f6c816b2c254db52b58834b5dbf8268287382f7a2f9735c8f" +[[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.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.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.wheels.hashes] -sha256 = "1af774c24117d366b69142d4f35edee518617f8b926d13560d0334cbe5aa3f85" +[packages.sdist.hashes] +sha256 = "e36576e0985e01027e999891e7a966c67822d40c89ef1b4c8b6e22737e3590b4" [[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 +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 = "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 +sha256 = "1b82aa4b37137248bd687055e109723debeb07b489de1565297dc9589c5f2d6b" -[packages.wheels.hashes] -sha256 = "6d5f178c91d50fb62461b5e579028b7d2e92872e1d8f02e96ace26464c2277a6" +[[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.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.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.wheels.hashes] -sha256 = "95a410f10b26cc3861757b626727db9d979deba7d2a3ab59f964fac8f0514fdc" +[packages.sdist.hashes] +sha256 = "7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1" [[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 +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 = "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 +sha256 = "202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88" -[packages.wheels.hashes] -sha256 = "b55c28a49bd2676eac9b3540a1dcc4cc053e4671c65dcec9fdad3dfe55fb148c" +[[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.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.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.wheels.hashes] -sha256 = "4c9f22edeb8176f8ab189337e8cbd8f92b66744534a2886df9e252b52ade991c" +[packages.sdist.hashes] +sha256 = "a62b301d96cf5489cb96ab023b32e068421867fdd5df2e42eece877869047fa2" [[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 +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 = "0c186185cad57a1469b851096b51345de7b625e353c1f4acd338402daed22fa7" +sha256 = "cb7654125e3dcb594269a6feb56ac693f1f13b7c654493a93eba72dc22d18034" -[[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]] +name = "pyyaml" +version = "6.0.3" +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/" -[packages.wheels.hashes] -sha256 = "5e38302ac218d2404758241093b5542ed0345d4f569096589f1810034244e998" +[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.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.sdist.hashes] +sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" -[packages.wheels.hashes] -sha256 = "0146637ed2321717280bfe4e4b20a18813b741da280e8039d7df2bf22b0251f1" +[[packages]] +name = "release-creator" +version = "3.1.0" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.15" +dependencies = [ + { name = "docutils" }, + { name = "markdown-it-py" }, + { name = "mdformat" }, + { name = "mdformat-gfm" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "towncrier" }, +] +index = "https://pypi.org/simple/" -[[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.sdist] +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.wheels.hashes] -sha256 = "9fc56322a1aff29a19088d2025f3c42a6d25a7035668048bb908c9f8b66f480d" +[packages.sdist.hashes] +sha256 = "7fba39aa52283c7edb4bf906656296a0696b4d23f6d6df85ef5d9a7abca7e1b2" [[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 +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 = "1d4967c8f7bfd67c9708115b41d7049197c5d20c76fb2e136fe78b0437ada05b" +sha256 = "e378a399af800452180ca1748dfb981b9f0115b634587226be927162b4b34f17" [[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" +name = "setuptools" +version = "84.0.0" +marker = "\"build\" in dependency_groups" +requires-python = ">=3.10" 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 +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 = "dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5" +sha256 = "f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73" [[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 +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 = "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" +sha256 = "51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670" [[packages]] -name = "typing-inspection" -version = "0.4.4" -marker = "\"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +name = "tomli-w" +version = "1.2.0" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.9" 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 +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 = "547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47" +sha256 = "2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021" [[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 +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 = "65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147" +sha256 = "188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90" [[packages]] -name = "urllib3" -version = "2.8.0" -marker = "\"lock-tools\" in dependency_groups or \"release-attestations\" in dependency_groups or \"test\" in dependency_groups" +name = "tomlkit" +version = "0.15.2" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.9" 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 +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 = "63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63" +sha256 = "83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6" [[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 +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 = "0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3" +sha256 = "05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437" [[packages]] -name = "uv" -version = "0.13.5" -marker = "\"release-resolver\" in dependency_groups or \"test\" in dependency_groups" +name = "towncrier" +version = "26.9.0" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "click" }, + { name = "jinja2" }, +] 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 +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 = "979409b8c9a78df140ffb19c53f511b7a73526da8d1d5e36558833055e371c6c" +sha256 = "ace9031631c718cc0709107b8755e6e31350eb8acdc877af43df3177addb9a55" [[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 +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 = "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 +sha256 = "ae4d223e6aadaff98d29b7f09e58d9c9ccf4cd3b455a2d2e0486d432cd8bf660" -[packages.wheels.hashes] -sha256 = "337a14829bb499e114989df4f908f134ff37f1fb6c33497541cfb05b28080e39" +[[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.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.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.wheels.hashes] -sha256 = "a42c85fa8156e1e5e2cecdbc77bb818ea1dcf5f78b8e2a8f84c7fe4f16f0702e" +[packages.sdist.hashes] +sha256 = "a68602f64421b199016127364ee47347b958391d19c2b2e53fe95673a8fe98e9" [[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 +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 = "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 +sha256 = "23d614cb703e1f7bd39e00c046d4c261b63fb9986e185134c6be2f93531d9726" -[packages.wheels.hashes] -sha256 = "4f62680fe58c5a83fd539e38f1956c2b577b1f5939c9020aa4c7f0addda2f4ad" +[[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]] -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 +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 = "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 +sha256 = "f2e7de8f12d7ffd850d2a3dcebf278cf72a8952a9abe75d84cfb54a76ee17ae4" -[packages.wheels.hashes] -sha256 = "f867c5ec24c563afaae730a13e46cdf74a1a0b64c134286ff99762a96e2abf40" +[[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.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.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.wheels.hashes] -sha256 = "d3fd5db55c20feb672ea457361aba29ab68b9ae69a14d98ad6c138b5f86dcca4" +[packages.sdist.hashes] +sha256 = "97418f2febec7da57d010ca6ffed8c7d56be9d425f381b74c7fe8d0213fccad9" [[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 +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 = "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 +sha256 = "a596c0726690e2182cc85dfbc2f39e6fddacb8f7ea08bf8bace122a17397cd13" -[packages.wheels.hashes] -sha256 = "fa8d26e08e34c29c0c4e820cf3181757086d547240b4f4d31ff1d8bd810ca946" +[[packages]] +name = "trove-classifiers" +version = "2026.9.21.13" +marker = "\"test\" in dependency_groups" +index = "https://pypi.org/simple/" -[[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.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.wheels.hashes] -sha256 = "91e58e304b63b6c91bac5561459ab0c3ea24f17bc01ea9359d69fed72142978f" +[packages.sdist.hashes] +sha256 = "0a9ebc8d4e2f3e8a22848c5258033035bec17a3012ac3fea16dbaa764489eb71" [[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 +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 = "309a74d3c488bfaa3ba7bff759da73193d8781729c0a01274f4940fd32bb5712" +sha256 = "8b1ff4f9c191b1040b71c37f1e445ab99732911e3cd91de52838453a854d7a17" -[[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]] +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.wheels.hashes] -sha256 = "469cca66de1b17c75b9b9de806a64cffb57cba5e42b1e30d0bba6fa72a146f58" +[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]] -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 +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 = "2386e8dbfe110ff2f8ec8bd344dd18bf39eb9eb54e1edeeb87c3b85c34229cd3" +sha256 = "d768e5fc57c86dfc5ec9365102e9b092541cd6954b35d8c1eea01a84f35a762a" -[[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]] +name = "ty" +version = "0.0.89" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" -[packages.wheels.hashes] -sha256 = "d162669e3a7811c584b03690eaa11e796ad1d309aa8229d5dfe3cbcd68a90400" +[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]] -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 +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 = "f4e55a6e2a1089fe74ed50fbdf2df0f6f336ee148ad232b05932db5deee8007e" +sha256 = "95a410f10b26cc3861757b626727db9d979deba7d2a3ab59f964fac8f0514fdc" -[[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]] +name = "uv" +version = "0.13.1" +marker = "\"test\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" -[packages.wheels.hashes] -sha256 = "1fb29fc46ba12033e2ef95a95ac79d649e71bbb1358419bbbcbd19d86026ef2f" +[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]] -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 +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 = "0548460d46c8667488b1f089e936f29f0edfb1cc753f9152ffbd8bc84a46aa20" +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" -upload-time = 2026-10-18 07:06:34+00:00 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" -upload-time = 2026-10-18 07:06:32+00:00 size = 5495268 [packages.wheels.hashes] @@ -4587,172 +1406,32 @@ 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" +marker = "\"release-prepare\" in dependency_groups or \"release-validate\" 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" -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]] +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" -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]] +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" -upload-time = 2026-10-05 00:23:21+00:00 size = 301667 [packages.wheels.hashes] @@ -4762,90 +1441,44 @@ sha256 = "89ca642c5bf0101157a09366be69fad0379db1f700ae39a920e103234573670e" 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" -upload-time = 2026-09-27 18:33:41+00:00 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" -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" +[tool.nab] +nab-version = "0.0.22" +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" +python-specifier = ">=3.15,<3.16" +platforms = [ + "linux_x86_64", +] diff --git a/pyproject.toml b/pyproject.toml index 479adbd..00dd120 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,22 +9,10 @@ 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" ] +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", - "check-wheel-contents>=0.6.3", "coverage[toml]>=7.10", "hatchling>=1.28", "pytest>=9", @@ -36,19 +24,20 @@ test = [ "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.nab] +mode = "universal" +uploaded-prior-to = "P7D" + +[tool.nab.matrix] +python = ">=3.15,<3.16" +platforms = ["linux_x86_64"] + +# 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-25T21:03:31Z [tool.ruff] line-length = 120 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/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-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/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index be30d3c..58c3927 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,18 @@ 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" + 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 + exclude-newer-package: + description: Newline-separated name=date cutoffs that override exclude-newer for single packages + default: "" 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,89 +47,52 @@ 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 }} + 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 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 + - name: Install Nab 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 + env: + EXCLUDE_NEWER: ${{ inputs.exclude-newer }} + EXCLUDE_NEWER_PACKAGE: ${{ inputs.exclude-newer-package }} + 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: 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=() ;; + 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[@]}" + 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 - name: Resolve optional Nab matrix if: inputs.additional-lock != '' shell: bash @@ -137,6 +106,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 +174,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/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/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/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_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_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_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/check_hook_update_cooldown/test_workflow.py b/tests/check_hook_update_cooldown/test_workflow.py deleted file mode 100644 index a3ded9f..0000000 --- a/tests/check_hook_update_cooldown/test_workflow.py +++ /dev/null @@ -1,129 +0,0 @@ -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) - 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 = [ - step.get("run", "") - for path in (*root.glob(".github/workflows/*.yaml"), *root.glob("*/action.yml")) - for document in [yaml.load(path.read_text(), Loader=yaml.BaseLoader)] - for steps in ( - [document["runs"]["steps"]] - if "runs" in document - else [job.get("steps", []) for job in document["jobs"].values()] - ) - for step in steps - ] - commands = [ - command - for run in runs - 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 - - -def test_prepare_hook_install_uses_install_time_cutoff() -> 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 ") - ] - - -def test_cooled_prek_isolates_hook_installs(tmp_path: Path) -> None: - fake = tmp_path / "prek" - fake.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >args\nenv >env\n') - fake.chmod(0o755) - leaked = dict.fromkeys(("UV_INDEX_URL", "UV_EXCLUDE_NEWER_PACKAGE", "UV_SYSTEM_CERTS", "SSL_CERT_FILE"), "leak") - - subprocess.run( - [str(Path(__file__).resolve().parents[2] / "tools/cooled_prek.sh"), "run", "--all-files"], - cwd=tmp_path, - env=os.environ | leaked | {"PATH": f"{tmp_path}:{os.environ['PATH']}", "UV_EXCLUDE_NEWER": "1 day"}, - check=True, - ) - - assert (tmp_path / "args").read_text().splitlines() == ["--config", ".pre-commit-config.yaml", "run", "--all-files"] - environment = dict(line.split("=", 1) for line in (tmp_path / "env").read_text().splitlines() if "=" in line) - assert {name: environment.get(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 not leaked.keys() & environment.keys() 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..2a315b0 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 @@ -10,22 +11,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 -_PyPIFile = TypedDict( - "_PyPIFile", - {"filename": str, "url": str, "hashes": dict[str, str], "upload-time": str}, -) +CALLER: Final = Path(__file__).parent / "fixtures/caller" @pytest.fixture @@ -51,12 +47,22 @@ 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] scripts = tmp_path_factory.mktemp("action-steps") for action_name in ( "prepare-towncrier", + "cooled-prek", "validate-release-patch", "commit-and-tag", "render-release-notes", @@ -71,10 +77,7 @@ 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", ): action = yaml.safe_load((source / action_name / "action.yml").read_text()) for index, step in enumerate(action["runs"]["steps"]): @@ -86,75 +89,12 @@ 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(Path(__file__).resolve().parents[1] / "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: - 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, ) @@ -195,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 @@ -255,9 +212,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" @@ -273,14 +230,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") @@ -298,12 +247,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") @@ -317,20 +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", - "GENERATED_NOTES": "", "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] = ( @@ -372,8 +309,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("$/") @@ -468,11 +403,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) @@ -498,7 +428,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 } @@ -558,26 +487,11 @@ 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."): _, 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.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": @@ -591,22 +505,8 @@ 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", "run", ] diff --git a/tests/cooled_prek/test_cooled_prek.py b/tests/cooled_prek/test_cooled_prek.py new file mode 100644 index 0000000..ed0b51a --- /dev/null +++ b/tests/cooled_prek/test_cooled_prek.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import os +import re +import subprocess +from pathlib import Path + +import yaml +from conftest import action + + +def test_no_hook_run_bypasses_cooled_prek() -> None: + root = Path(__file__).resolve().parents[2] + runs = [ + step.get("run", "") + for path in (*root.glob(".github/workflows/*.yaml"), *root.glob("*/action.yml")) + for document in [yaml.load(path.read_text(), Loader=yaml.BaseLoader)] + for steps in ( + [document["runs"]["steps"]] + if "runs" in document + else [job.get("steps", []) for job in document["jobs"].values()] + ) + for step in steps + ] + commands = [ + command + for run in runs + for command in re.findall(r"\bprek (?:run|install-hooks|update)\b[^\n]*", run.replace("\\\n", " ")) + ] + + assert not commands, commands + + +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") + + 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", + ] + + +def test_cooled_prek_isolates_hook_installs(tmp_path: Path) -> None: + fake = tmp_path / "prek" + fake.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >args\nenv >env\n') + fake.chmod(0o755) + leaked = dict.fromkeys(("UV_INDEX_URL", "UV_EXCLUDE_NEWER_PACKAGE", "UV_SYSTEM_CERTS", "SSL_CERT_FILE"), "leak") + + subprocess.run( + [str(Path(__file__).resolve().parents[2] / "tools/cooled_prek.sh"), "run", "--all-files"], + cwd=tmp_path, + env=os.environ | leaked | {"PATH": f"{tmp_path}:{os.environ['PATH']}", "UV_EXCLUDE_NEWER": "1 day"}, + check=True, + ) + + assert (tmp_path / "args").read_text().splitlines() == ["--config", ".pre-commit-config.yaml", "run", "--all-files"] + environment = dict(line.split("=", 1) for line in (tmp_path / "env").read_text().splitlines() if "=" in line) + assert {name: environment.get(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 not leaked.keys() & environment.keys() 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 ceaa111..412b17d 100644 --- a/tests/export_build_constraints/test_export.py +++ b/tests/export_build_constraints/test_export.py @@ -5,20 +5,19 @@ import sys import tomllib from collections.abc import Callable -from datetime import UTC, datetime +from copy import deepcopy from pathlib import Path from textwrap import dedent from typing import cast 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] = {} @@ -43,12 +41,12 @@ 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: 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, @@ -94,47 +92,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 = source / "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(source / "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"), [ @@ -169,12 +126,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", @@ -202,7 +153,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)]) @@ -256,33 +207,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 = source / "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: @@ -306,53 +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)) - result = action( - tmp_path, - tmp_path, - action_scripts, - "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"), - ) - 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) @@ -430,11 +329,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..68b57a3 --- /dev/null +++ b/tests/fixtures/caller/pyproject.toml @@ -0,0 +1,77 @@ +[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.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 }, +] 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..adb678a --- /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_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 79bb775..e0c3306 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -5,13 +5,12 @@ import subprocess import sys import tomllib -from copy import deepcopy -from datetime import UTC, datetime, timedelta from pathlib import Path import pytest import tomlkit -from conftest import action +import yaml +from conftest import CALLER, action @pytest.mark.parametrize( @@ -22,11 +21,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 +54,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, @@ -69,64 +68,17 @@ 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(source / "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: - source = Path(__file__).resolve().parents[2] - action_root = tmp_path / "install-locked-tools" - action_root.mkdir() - lock = (source / "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) -> None: - source = Path(__file__).resolve().parents[2] - 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) +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")} + 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 +86,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 +98,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 +113,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 +124,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 +150,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", @@ -215,240 +162,9 @@ 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: - 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(source / "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((source / "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(source / "pylock.toml"), - "--local-tool-source", - ], - 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 - - -@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((source / "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_checks_build_group_with_selected_groups(tmp_path: Path) -> 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"] == "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"], - 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((source / "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"], - 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((source / "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((source / "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((source / "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((source / "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((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,70 +182,6 @@ 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: - 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)) - - 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 "uncooled artifact" 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()) - 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(source / "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: - 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()) - 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( @@ -539,143 +191,13 @@ 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() + assert 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 = (source / "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((source / "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((source / "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: - source = Path(__file__).resolve().parents[2] - lock = (source / "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 - - -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 +207,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 +215,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 +226,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", @@ -724,51 +246,21 @@ def test_install_locked_tools_exporter_does_not_build_pyyaml(tmp_path: Path, act 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: - 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 / "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: - 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") 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(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 +270,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( @@ -794,38 +286,6 @@ def test_install_locked_tools_ignores_resolution_cutoff_for_hashed_lock(tmp_path assert installed_version == locked_version -def test_shared_lock_records_upload_times() -> None: - packages = tomllib.loads((Path(__file__).resolve().parents[2] / "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: - source = Path(__file__).resolve().parents[2] - overrides = tomllib.loads((source / "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()) - 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, @@ -833,7 +293,6 @@ def install( *, groups: str, venv: Path, - lock_source: str = "caller", extra_env: dict[str, str] | None = None, ) -> subprocess.CompletedProcess[str]: return action( @@ -844,6 +303,46 @@ 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 + + +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", + ] diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py deleted file mode 100644 index de94c81..0000000 --- a/tests/install_locked_tools/test_metadata.py +++ /dev/null @@ -1,603 +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 - - -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( - 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' - + f'directory = {{ path = "{path}" }}\n' - ) - - with pytest.raises(SystemExit, match="2"): - _run_check(lock_case, monkeypatch, "--local-tool-source") - - assert "direct-source package" 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 1860701..d94494c 100644 --- a/tests/install_locked_tools/test_secondary_lock.py +++ b/tests/install_locked_tools/test_secondary_lock.py @@ -1,13 +1,11 @@ from __future__ import annotations from pathlib import Path -from textwrap import dedent -import pytest 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 +14,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", ) @@ -29,89 +26,15 @@ def test_verify_secondary_lock_accepts_cooled_selection(tmp_path: Path, action_s 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, 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( - Path(__file__).resolve().parents[2], - 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)}), - ) - - 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", ) 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, 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( - Path(__file__).resolve().parents[2], - tmp_path, - action_scripts, - "install-locked-tools", - version="", - LOCK_SOURCE="shared", - 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/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py index cccde66..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, @@ -383,3 +385,45 @@ 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 + + +@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 ("### New Contributors" in (repository / "CHANGELOG.md").read_text()) is credited 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/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/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..3decc32 --- /dev/null +++ b/tests/refresh_release_lock/test_refresh.py @@ -0,0 +1,295 @@ +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()) + + +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 + + +@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}", + ] + + +@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", "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"), + ], +) +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 }}" + + +@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 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/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index e2fa411..9c26555 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( @@ -76,56 +77,14 @@ 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"] 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 }}" @@ -136,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( [ @@ -163,3 +123,41 @@ 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 + + +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: diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py index 3fe25f4..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,32 +61,26 @@ 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", "artifact", "additional_lock", "keep_uv_lock", "accepted"), + ("path", "additional_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", "", 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_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, ) -> None: (lock_repository / path).write_text("before\n") @@ -126,20 +89,17 @@ 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 +118,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: @@ -301,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_lock_groups.py b/tests/validate_news/test_lock_groups.py deleted file mode 100644 index 3e19182..0000000 --- a/tests/validate_news/test_lock_groups.py +++ /dev/null @@ -1,131 +0,0 @@ -from __future__ import annotations - -import runpy -import subprocess -import sys -from pathlib import Path -from typing import Final - -import pytest - -from tools.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' - ) - 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/validate_news/test_news.py b/tests/validate_news/test_news.py index 64d3b3e..dc54021 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"), }, @@ -197,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") @@ -212,8 +222,7 @@ def test_validate_news_rejects_removed_release_lock(repository: Path, tmp_path: env=os.environ | { "BASE_REF": "main", - "ADDITIONAL_LOCK": "", - "KEEP_UV_LOCK": "false", + "ADDITIONAL_LOCK": additional_lock, "GITHUB_OUTPUT": str(tmp_path / "lock-change-output"), "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "validate-news"), }, @@ -222,5 +231,50 @@ 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( + ("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/tests/validate_python_release/test_tagged.py b/tests/validate_python_release/test_tagged.py index 4f39410..a325176 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,52 +47,27 @@ 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_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"]] -def test_validator_builds_reproducible_distributions_and_checks_them( + 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( build_repository: Path, build_constraints: Path, tmp_path: Path, release_workflow: dict[str, Any] ) -> None: assert "hatchling==" in build_constraints.read_text() @@ -111,17 +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 - 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 def test_validator_rejects_nonempty_distribution_directory(tmp_path: Path, release_workflow: dict[str, Any]) -> None: @@ -162,7 +126,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 +146,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 +199,37 @@ def test_validator_matches_built_version_to_tag( check=False, ) assert (result.returncode == 0) is accepted, result.stderr + + +@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() + 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")) 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/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 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_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"] diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index a2c7bd4..50bb965 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -3,40 +3,18 @@ from __future__ import annotations import argparse -import json -import ssl -import time import tomllib -from collections import defaultdict -from collections.abc import Iterable, Iterator -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 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.pylock import Pylock, PylockValidationError from packaging.utils import canonicalize_name -from packaging.version import InvalidVersion, Version 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("--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) args = parser.parse_args() try: groups = {"build"} if args.build_constraints else set(args.groups) @@ -48,205 +26,14 @@ 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)}") - 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"]) - 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, 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: - 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: - 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: + except (KeyError, OSError, PylockValidationError, TypeError, ValueError) as error: parser.error(str(error)) -def _check_cooling(lock: Pylock, groups: set[str], *, 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") - 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) - 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: - 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, reviewed_cutoff(package.name, str(package.version)) or 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 _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): - 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], *, local_tool_source: bool) -> 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 - - -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 - - -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/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/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/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/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/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_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"] 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..2fed661 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: "" @@ -22,6 +19,15 @@ 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 + exclude-newer-package: + description: Newline-separated name=date cutoffs that override exclude-newer for single packages + default: "" runs: using: composite steps: @@ -47,18 +53,20 @@ 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" 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 @@ -76,5 +84,7 @@ 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 }} + nab-git-commit: ${{ inputs.nab-git-commit }} + exclude-newer: ${{ inputs.exclude-newer }} + exclude-newer-package: ${{ inputs.exclude-newer-package }} mode: check 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-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" 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