From 1af0a6a64e752db8e877bba8af6ed47a9bf37a8f Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Fri, 14 Aug 2026 13:43:17 -0500 Subject: [PATCH 1/3] feat(release): add immutable SemVer publication lane Separate release intent from deployment identity by testing the final release commit before any source or artifact publication. Keep the lane disarmed until the registry, environment, baseline tag, and repository protections are reviewed. --- .github/workflows/release.yml | 336 +++++++++++++ .github/workflows/test.yml | 34 +- CHANGELOG.md | 5 + Makefile | 9 +- README.md | 63 ++- pyproject.toml | 40 +- scripts/__init__.py | 1 + scripts/release_contract.py | 567 ++++++++++++++++++++++ tests/test_release_contract.py | 407 ++++++++++++++++ uv.lock | 840 +++++++++++++++++++++++++++++++++ 10 files changed, 2276 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 scripts/__init__.py create mode 100644 scripts/release_contract.py create mode 100644 tests/test_release_contract.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5b441b0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,336 @@ +--- +# yamllint disable rule:line-length rule:truthy +name: release + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: semantic-release-main + cancel-in-progress: false + +jobs: + activation: + runs-on: ubuntu-latest + outputs: + enabled: ${{ steps.gate.outputs.enabled }} + steps: + - name: Enforce reviewed activation gate + id: gate + env: + RELEASE_ENABLED: ${{ vars.SEMANTIC_RELEASE_ENABLED }} + run: | + if [ "$RELEASE_ENABLED" != "true" ]; then + echo "Semantic release is disarmed. See README activation prerequisites." + echo "enabled=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "enabled=true" >> "$GITHUB_OUTPUT" + + prepare: + needs: activation + if: needs.activation.outputs.enabled == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + artifact_name: ${{ steps.identity.outputs.artifact_name }} + previous_version: ${{ steps.intent.outputs.previous_version }} + released: ${{ steps.intent.outputs.released }} + release_sha: ${{ steps.identity.outputs.release_sha }} + tag: ${{ steps.identity.outputs.tag }} + version: ${{ steps.identity.outputs.version }} + steps: + - name: Check out the triggering main revision + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + ref: ${{ github.sha }} + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + python-version: "3.11" + enable-cache: true + - name: Install the frozen release toolchain + run: uv sync --frozen + - name: Validate the tagged baseline and strict release intent + id: intent + env: + GH_TOKEN: ${{ github.token }} + run: | + baseline="$(uv run python scripts/release_contract.py validate-baseline --repository .)" + baseline_tag="$(python -c 'import json,sys; print(json.load(sys.stdin)["tag"])' <<< "$baseline")" + uv run python scripts/release_contract.py validate-history \ + --repository . --baseline-tag "$baseline_tag" + previous_version="$(uv run semantic-release version --print-last-released)" + version="$(uv run semantic-release version --print)" + { + echo "previous_version=$previous_version" + if [ -z "$version" ] || [ "$version" = "$previous_version" ]; then + echo "No patch, minor, or breaking release intent." >&2 + echo "released=false" + else + tag="v$version" + if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then + echo "Refusing existing release tag $tag" >&2 + exit 1 + fi + echo "released=true" + echo "version=$version" + fi + } >> "$GITHUB_OUTPUT" + - name: Materialize the release commit and tag locally + if: steps.intent.outputs.released == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + uv run semantic-release --strict version --no-push --no-vcs-release + - name: Bind the final local release identity + if: steps.intent.outputs.released == 'true' + id: identity + env: + EXPECTED_PARENT: ${{ github.sha }} + EXPECTED_VERSION: ${{ steps.intent.outputs.version }} + run: | + release_sha="$(git rev-parse HEAD)" + tag="v$EXPECTED_VERSION" + test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT" + test "$(git rev-list -n 1 "$tag")" = "$release_sha" + test -z "$(git status --porcelain)" + unexpected="$(git diff --name-only "$EXPECTED_PARENT" "$release_sha" | grep -Ev '^(CHANGELOG\.md|pyproject\.toml|uv\.lock)$' || true)" + test -z "$unexpected" + { + echo "release_sha=$release_sha" + echo "tag=$tag" + echo "version=$EXPECTED_VERSION" + echo "artifact_name=release-$EXPECTED_VERSION-$release_sha" + } >> "$GITHUB_OUTPUT" + - name: Reject an already-published PyPI version + if: steps.intent.outputs.released == 'true' + env: + VERSION: ${{ steps.identity.outputs.version }} + run: | + status="$(curl --silent --show-error --output /dev/null \ + --write-out '%{http_code}' \ + "https://pypi.org/pypi/hermes-plugin-kit/$VERSION/json")" + case "$status" in + 404) ;; + 200) + echo "Refusing existing PyPI version $VERSION" >&2 + exit 1 + ;; + *) + echo "PyPI version preflight was inconclusive: HTTP $status" >&2 + exit 1 + ;; + esac + - name: Prove the exact release SHA with unit and public contracts + if: steps.intent.outputs.released == 'true' + env: + RELEASE_SHA: ${{ steps.identity.outputs.release_sha }} + run: | + test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + make test + - name: Prove the exact release SHA against Hermes + if: steps.intent.outputs.released == 'true' + env: + RELEASE_SHA: ${{ steps.identity.outputs.release_sha }} + run: | + test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + make test-contract + git -C .hermes-agent rev-parse HEAD > hermes-source-sha.txt + - name: Build the exact release SHA once and validate metadata + if: steps.intent.outputs.released == 'true' + env: + RELEASE_SHA: ${{ steps.identity.outputs.release_sha }} + run: | + test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + make build + make check-dist + - name: Create and verify the immutable release receipt + if: steps.intent.outputs.released == 'true' + env: + PREVIOUS_VERSION: ${{ steps.intent.outputs.previous_version }} + RELEASE_SHA: ${{ steps.identity.outputs.release_sha }} + TAG: ${{ steps.identity.outputs.tag }} + VERSION: ${{ steps.identity.outputs.version }} + run: | + uv run python scripts/release_contract.py create-receipt \ + --previous-version "$PREVIOUS_VERSION" \ + --version "$VERSION" \ + --tag "$TAG" \ + --source-sha "$RELEASE_SHA" \ + --hermes-source-sha "$(cat hermes-source-sha.txt)" \ + --workflow "release.yml" \ + --run-id "${{ github.run_id }}" \ + --run-attempt "${{ github.run_attempt }}" \ + --artifact-dir dist \ + --output release-receipt.json + uv run python scripts/release_contract.py verify-receipt \ + --receipt release-receipt.json \ + --artifact-dir dist + - name: Bundle the tested source and immutable artifacts + if: steps.intent.outputs.released == 'true' + env: + RELEASE_SHA: ${{ steps.identity.outputs.release_sha }} + TAG: ${{ steps.identity.outputs.tag }} + run: | + git branch release-candidate "$RELEASE_SHA" + git bundle create release-source.bundle \ + refs/heads/release-candidate "refs/tags/$TAG" + git branch --delete --force release-candidate + sha256sum release-source.bundle release-receipt.json dist/* \ + > release-payload.sha256 + - name: Upload the tested release payload + if: steps.intent.outputs.released == 'true' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: ${{ steps.identity.outputs.artifact_name }} + path: | + dist/ + release-payload.sha256 + release-receipt.json + release-source.bundle + if-no-files-found: error + compression-level: 0 + retention-days: 90 + + promote-source: + needs: prepare + if: needs.prepare.outputs.released == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + artifact_name: ${{ needs.prepare.outputs.artifact_name }} + release_sha: ${{ needs.prepare.outputs.release_sha }} + tag: ${{ needs.prepare.outputs.tag }} + version: ${{ needs.prepare.outputs.version }} + steps: + - name: Check out the guarded parent revision + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + ref: ${{ github.sha }} + - name: Download the tested release payload + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: ${{ needs.prepare.outputs.artifact_name }} + - name: Validate and atomically push only the tested source + env: + RELEASE_SHA: ${{ needs.prepare.outputs.release_sha }} + TAG: ${{ needs.prepare.outputs.tag }} + TRIGGER_SHA: ${{ github.sha }} + run: | + sha256sum --check release-payload.sha256 + git bundle verify release-source.bundle + git fetch release-source.bundle \ + refs/heads/release-candidate:refs/heads/release-candidate \ + "refs/tags/$TAG:refs/tags/$TAG" + test "$(git rev-parse refs/heads/release-candidate)" = "$RELEASE_SHA" + test "$(git rev-parse "$RELEASE_SHA^")" = "$TRIGGER_SHA" + test "$(git rev-list -n 1 "$TAG")" = "$RELEASE_SHA" + unexpected="$(git diff --name-only "$TRIGGER_SHA" "$RELEASE_SHA" | grep -Ev '^(CHANGELOG\.md|pyproject\.toml|uv\.lock)$' || true)" + test -z "$unexpected" + remote_main="$(git ls-remote origin refs/heads/main | cut -f1)" + test "$remote_main" = "$TRIGGER_SHA" + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then + echo "Refusing existing release tag $TAG" >&2 + exit 1 + fi + git push --atomic origin \ + "$RELEASE_SHA:refs/heads/main" \ + "refs/tags/$TAG:refs/tags/$TAG" + + publish: + needs: promote-source + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/hermes-plugin-kit/${{ needs.promote-source.outputs.version }}/ + permissions: + id-token: write + steps: + - name: Download the tested release payload + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: ${{ needs.promote-source.outputs.artifact_name }} + - name: Recheck the immutable payload before publication + run: sha256sum --check release-payload.sha256 + - name: Publish the tested distributions with Trusted Publishing + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: dist/ + # Safe because the next job downloads and hash-verifies every PyPI file. + skip-existing: true + + verify-pypi: + needs: [promote-source, publish] + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out the published source identity + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ needs.promote-source.outputs.release_sha }} + - name: Download the published release payload + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: ${{ needs.promote-source.outputs.artifact_name }} + - name: Verify PyPI filenames, metadata hashes, and downloaded bytes + env: + RELEASE_SHA: ${{ needs.promote-source.outputs.release_sha }} + run: | + test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + sha256sum --check release-payload.sha256 + python scripts/release_contract.py verify-pypi \ + --receipt release-receipt.json + + github-release: + needs: [promote-source, publish, verify-pypi] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download the published release payload + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: ${{ needs.promote-source.outputs.artifact_name }} + - name: Verify the payload published by the preceding job + run: sha256sum --check release-payload.sha256 + - name: Create the discoverable GitHub Release and receipt + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ needs.promote-source.outputs.release_sha }} + TAG: ${{ needs.promote-source.outputs.tag }} + VERSION: ${{ needs.promote-source.outputs.version }} + run: | + repository_url="https://github.com/$GITHUB_REPOSITORY.git" + remote_tag="$(git ls-remote "$repository_url" "refs/tags/$TAG^{}" | cut -f1)" + if [ -z "$remote_tag" ]; then + remote_tag="$(git ls-remote "$repository_url" "refs/tags/$TAG" | cut -f1)" + fi + test "$remote_tag" = "$RELEASE_SHA" + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + mkdir existing-release + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ + --dir existing-release + cmp release-receipt.json existing-release/release-receipt.json + for artifact in dist/*; do + cmp "$artifact" "existing-release/$(basename "$artifact")" + done + else + gh release create "$TAG" dist/* release-receipt.json \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --title "hermes-plugin-kit $VERSION" \ + --generate-notes + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c0b269b..ae85ec1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,33 +1,51 @@ +--- +# yamllint disable rule:line-length rule:truthy name: test on: push: pull_request: +permissions: + contents: read + jobs: unittest: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v8.2.0 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: python-version: "3.11" enable-cache: true - name: Run unittest suite run: make test + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + python-version: "3.11" + enable-cache: true + - name: Build and validate package metadata + run: | + make build + make check-dist + # Amber admission gates on the exact deployed Hermes fork contract. Keep the # immutable revision aligned with infra's candidate image/source receipt. hermes-deployed-context-engine-contract: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: repository: offendingcommit/hermes-agent ref: d3b1cfe9a80531e0682b1e66752e04cea29d5d4a path: .hermes-agent-deployed - - uses: astral-sh/setup-uv@v8.2.0 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: python-version: "3.11" enable-cache: true @@ -45,12 +63,12 @@ jobs: runs-on: ubuntu-latest continue-on-error: true steps: - - uses: actions/checkout@v6 - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: repository: NousResearch/hermes-agent path: .hermes-agent - - uses: astral-sh/setup-uv@v8.2.0 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: python-version: "3.11" enable-cache: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..aaf4725 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + + + +Release notes are generated from Conventional Commits by Python Semantic Release. diff --git a/Makefile b/Makefile index 1b81e53..4f6c75b 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ UV ?= uv HERMES_AGENT_REPO ?= https://github.com/NousResearch/hermes-agent.git HERMES_AGENT_DIR ?= .hermes-agent -.PHONY: help install test test-one test-contract build clean +.PHONY: help install test test-one test-release test-contract build check-dist clean help: ## Show available targets @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \ @@ -18,6 +18,9 @@ test: ## Run the full unittest suite test-one: ## Run a single test: make test-one T=tests.test_kit.Class.method $(UV) run python -m unittest $(T) +test-release: ## Run deterministic release intent, identity, and workflow contracts + $(UV) run python -m unittest tests.test_release_contract -v + test-contract: ## Clone hermes-agent into a staging dir and run the contract tests against it @if [ -d "$(HERMES_AGENT_DIR)/.git" ]; then \ echo "Updating $(HERMES_AGENT_DIR)"; git -C "$(HERMES_AGENT_DIR)" pull --ff-only -q || true; \ @@ -27,8 +30,12 @@ test-contract: ## Clone hermes-agent into a staging dir and run the contract tes HERMES_AGENT_PATH="$(abspath $(HERMES_AGENT_DIR))" $(UV) run python -m unittest tests.test_hermes_contract -v build: ## Build the wheel/sdist distribution + rm -rf dist $(UV) build +check-dist: ## Validate wheel and sdist package metadata + $(UV) run twine check dist/* + clean: ## Remove Python caches and build artifacts find . -type d -name __pycache__ -prune -exec rm -rf {} + rm -rf .pytest_cache .coverage htmlcov dist build *.egg-info diff --git a/README.md b/README.md index 4fe3016..f8b4e54 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ > Lifecycle helpers for [hermes-agent](https://github.com/NousResearch/hermes-agent) plugins — convention-correct commands, tools, middleware, hooks, skills, validation, and safe logging, baked in. [![test](https://github.com/offendingcommit/hermes-plugin-kit/actions/workflows/test.yml/badge.svg)](https://github.com/offendingcommit/hermes-plugin-kit/actions/workflows/test.yml) +[![PyPI](https://img.shields.io/pypi/v/hermes-plugin-kit)](https://pypi.org/project/hermes-plugin-kit/) ![python](https://img.shields.io/badge/python-3.11%2B-blue) `hermes-plugin-kit` is a tiny, dependency-free helper for authoring plugins for @@ -80,23 +81,21 @@ and adds nothing to your runtime footprint — pure standard library. ## Install -The package is consumed straight from Git (works great with [uv](https://docs.astral.sh/uv/)): +Install a published release from PyPI with [uv](https://docs.astral.sh/uv/) or pip: ```bash -uv add git+https://github.com/offendingcommit/hermes-plugin-kit.git -# or -pip install git+https://github.com/offendingcommit/hermes-plugin-kit.git +uv add "hermes-plugin-kit>=0.7,<1" +# or: pip install "hermes-plugin-kit>=0.7,<1" ``` -With uv, pin it to an immutable commit in your plugin's `pyproject.toml`. -Profiles that load several plugins into one Python environment must keep every -plugin on the same kit revision: +Consumers declare the narrowest truthful compatibility range in +`pyproject.toml`; adopting a newer kit API and raising that lower bound are one +change. Repository locks remain exact for reproducible local tests. Fleet +deployment independently selects one qualified wheel filename and SHA-256 for +every co-loaded plugin, so a movable branch is never a deployment identity. ```toml -dependencies = ["hermes-plugin-kit"] - -[tool.uv.sources] -hermes-plugin-kit = { git = "https://github.com/offendingcommit/hermes-plugin-kit.git", rev = "" } +dependencies = ["hermes-plugin-kit>=0.7,<1"] ``` ## Usage @@ -724,10 +723,50 @@ Uses [uv](https://docs.astral.sh/uv/). Install it with `brew install uv` (macOS) make install # uv sync — create/sync the dev environment make test # uv run python -m unittest discover -s tests make test-one T=tests.test_kit.SchemaConventionTests +make test-release # release intent, artifact identity, and workflow contracts +make test-contract # real upstream Hermes contract make build # uv build — wheel + sdist +make check-dist # validate wheel/sdist metadata with twine ``` -CI runs `make test` on `actions/checkout@v6` + `astral-sh/setup-uv@v8.2.0` (Python 3.11). +CI pins every Action to an immutable commit and runs the unit, package metadata, +and real-Hermes contract lanes with read-only repository permissions. + +## Releases + +Merges to `main` use Python Semantic Release 10.6.x and Conventional Commits: +`fix` produces a patch, `feat` produces a minor, and `!` or a +`BREAKING CHANGE:` footer produces a major. Documentation, test, CI, and chore +commits do not release by themselves; an invalid commit in release history +fails closed. Major releases are published but their receipt is always +`manual_migration_required`, never an automatic promotion candidate. + +The workflow first creates the version/CHANGELOG commit and tag locally. It +tests that exact final SHA with the unit and real-Hermes contract suites, builds +the wheel and sdist once, validates their metadata, and records their filenames +and SHA-256 values. Only then may a narrow write job atomically push that tested +commit and tag. A separate `pypi` environment publishes the uploaded artifacts +through OIDC Trusted Publishing; no password or API token is used. The GitHub +Release and its JSON receipt are created only after PyPI succeeds. A failed +publish is retried from the retained workflow artifact and must not rebuild it. + +Release automation is deliberately disarmed unless the repository variable +`SEMANTIC_RELEASE_ENABLED` is exactly `true`. Set it only after all activation +prerequisites have been reviewed: + +- a pending or existing PyPI Trusted Publisher is configured for + `offendingcommit/hermes-plugin-kit`, workflow `release.yml`, and environment + `pypi`; +- the protected GitHub environment is named exactly `pypi`; +- the `main` ruleset requires the ordinary test workflow and permits only the + guarded Semantic Release source-promotion job to advance the release commit; +- the existing `0.7.0` source baseline has a reviewed immutable `v0.7.0` tag. + +The repository currently creates none of those external controls itself. A +missing switch, PyPI project, baseline tag, test, build, metadata check, source +identity, or artifact hash stops before publication. The release receipt is the +discoverable boundary for downstream qualification; polling and recovery from +a missed notification belong to that downstream system. ## License diff --git a/pyproject.toml b/pyproject.toml index 9a444e5..d5bbc9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ # Dependency manifest for hermes-plugin-kit (uv-native, GitOps source of truth). -# Unlike the path-loaded plugins, this IS an installable library: consuming -# plugins add it as a dependency (private git source) and import it. So it keeps -# a build-system and stays buildable; it is not `package = false`. +# Unlike path-loaded plugins, this is a public installable library: consuming +# plugins declare a compatible PyPI range and import it. It keeps a build-system +# and stays buildable; it is not `package = false`. [build-system] requires = ["setuptools>=61"] build-backend = "setuptools.build_meta" @@ -12,7 +12,7 @@ version = "0.7.0" description = "Convention-correct middleware and lifecycle registration for hermes-agent plugins." readme = "README.md" requires-python = ">=3.11" -license = { text = "MIT" } +license = "MIT" authors = [{ name = "Offending Commit", email = "offendingcommit@gmail.com" }] keywords = ["hermes", "hermes-agent", "plugin", "llm-tools"] dependencies = ["pyyaml>=6"] @@ -23,7 +23,37 @@ Repository = "https://github.com/offendingcommit/hermes-plugin-kit" # The runtime uses PyYAML for strict SKILL.md validation. Contract tests import # current upstream Hermes source, whose plugin and gateway seams need these. [dependency-groups] -dev = ["httpx[socks]==0.28.1", "requests==2.33.0"] +dev = [ + "httpx[socks]==0.28.1", + "python-semantic-release==10.6.1", + "requests==2.33.0", + "twine==6.2.0", +] [tool.setuptools] packages = ["hermes_plugin_kit"] + +[tool.semantic_release] +allow_zero_version = true +assets = ["uv.lock"] +build_command = "uv lock --upgrade-package \"$PACKAGE_NAME\" && git add uv.lock" +commit_message = "chore(release): v{version}" +commit_parser = "conventional" +major_on_zero = true +tag_format = "v{version}" +version_toml = ["pyproject.toml:project.version"] + +[tool.semantic_release.branches.main] +match = "main" +prerelease = false + +[tool.semantic_release.commit_parser_options] +ignore_merge_commits = true +parse_squash_commits = true + +[tool.semantic_release.remote] +type = "github" +token = { env = "GH_TOKEN" } + +[tool.semantic_release.publish] +upload_to_vcs_release = false diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..c4712a9 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Repository automation helpers.""" diff --git a/scripts/release_contract.py b/scripts/release_contract.py new file mode 100644 index 0000000..ed1cf2e --- /dev/null +++ b/scripts/release_contract.py @@ -0,0 +1,567 @@ +#!/usr/bin/env python3 +"""Build and verify the immutable hermes-plugin-kit release receipt.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import subprocess +import tarfile +import time +import tomllib +import zipfile +from dataclasses import dataclass +from email.parser import Parser +from pathlib import Path +from typing import Callable, Iterable, Sequence +from urllib.parse import urlparse +from urllib.request import Request, urlopen + + +PACKAGE_NAME = "hermes-plugin-kit" +TAG_PREFIX = "v" +SEMVER_PATTERN = re.compile( + r"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)$" +) +SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$") + + +class ReleaseContractError(ValueError): + """Raised when release identity or evidence is incomplete or inconsistent.""" + + +@dataclass(frozen=True, order=True) +class SemVer: + major: int + minor: int + patch: int + + @classmethod + def parse(cls, value: str) -> "SemVer": + match = SEMVER_PATTERN.fullmatch(value) + if match is None: + raise ReleaseContractError(f"invalid stable SemVer: {value!r}") + return cls(*(int(match[name]) for name in ("major", "minor", "patch"))) + + +@dataclass(frozen=True) +class ReleaseBaseline: + version: str + tag: str + source_sha: str + + +def _run_git(repository: Path, *args: str, check: bool = True) -> str: + result = subprocess.run( + ["git", *args], + cwd=repository, + check=False, + capture_output=True, + text=True, + ) + if check and result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or "git command failed" + raise ReleaseContractError(detail) + return result.stdout.strip() + + +def _project_version(repository: Path) -> str: + with (repository / "pyproject.toml").open("rb") as handle: + project = tomllib.load(handle).get("project", {}) + if project.get("name") != PACKAGE_NAME: + raise ReleaseContractError(f"expected project name {PACKAGE_NAME!r}") + version = project.get("version") + if not isinstance(version, str): + raise ReleaseContractError("project.version must be a string") + SemVer.parse(version) + return version + + +def validate_release_baseline(repository: Path) -> ReleaseBaseline: + """Require a clean checkout whose declared version has an ancestral tag.""" + + repository = repository.resolve() + if _run_git(repository, "status", "--porcelain"): + raise ReleaseContractError("working tree is dirty") + version = _project_version(repository) + tag = f"{TAG_PREFIX}{version}" + tag_sha = _run_git(repository, "rev-list", "-n", "1", tag, check=False) + if not SHA_PATTERN.fullmatch(tag_sha): + raise ReleaseContractError( + f"baseline tag {tag} is missing; seed and review it before enabling releases" + ) + ancestor = subprocess.run( + ["git", "merge-base", "--is-ancestor", tag_sha, "HEAD"], + cwd=repository, + check=False, + capture_output=True, + text=True, + ) + if ancestor.returncode != 0: + raise ReleaseContractError(f"baseline tag {tag} is not an ancestor of HEAD") + return ReleaseBaseline(version=version, tag=tag, source_sha=tag_sha) + + +def _synthetic_commit(message: str): + from git import Actor, Repo + from git.objects.commit import Commit + + actor = Actor("Release Contract", "release-contract@example.invalid") + return Commit( + repo=Repo(), + binsha=Commit.NULL_BIN_SHA, + message=message, + author=actor, + authored_date=0, + committer=actor, + committed_date=0, + parents=[], + ) + + +def _classify_commits(commits: Iterable[object]) -> str | None: + from semantic_release.commit_parser.conventional import ConventionalCommitParser + from semantic_release.commit_parser.token import ParseError + from semantic_release.enums import LevelBump + + parser = ConventionalCommitParser() + strongest = LevelBump.NO_RELEASE + for commit in commits: + results = tuple(parser.parse(commit)) + if not results or any(isinstance(result, ParseError) for result in results): + message = str(getattr(commit, "message", "")) + subject = message.splitlines()[0] if message else "" + raise ReleaseContractError( + f"invalid conventional commit in release history: {subject!r}" + ) + strongest = max(strongest, *(result.bump for result in results)) + return { + LevelBump.NO_RELEASE: None, + LevelBump.PATCH: "patch", + LevelBump.MINOR: "minor", + LevelBump.MAJOR: "major", + }[strongest] + + +def classify_commit_messages(messages: Iterable[str]) -> str | None: + """Exercise PSR's configured conventional parser and return the strongest bump.""" + + return _classify_commits(_synthetic_commit(message) for message in messages) + + +def validate_conventional_history(repository: Path, baseline_tag: str) -> str | None: + """Reject non-merge commits PSR cannot parse between the baseline and HEAD.""" + + from git import Repo + + repository = repository.resolve() + if not re.fullmatch(r"v\d+\.\d+\.\d+", baseline_tag): + raise ReleaseContractError(f"invalid baseline tag: {baseline_tag!r}") + repo = Repo(repository) + try: + commits = [ + commit + for commit in repo.iter_commits(f"{baseline_tag}..HEAD") + if len(commit.parents) <= 1 + ] + except Exception as error: + raise ReleaseContractError( + f"cannot inspect conventional history from {baseline_tag}: {error}" + ) from error + return _classify_commits(reversed(commits)) + + +def _release_class(previous_version: str, version: str) -> str: + previous = SemVer.parse(previous_version) + current = SemVer.parse(version) + if current.major == previous.major + 1 and current.minor == current.patch == 0: + return "major" + if ( + current.major == previous.major + and current.minor == previous.minor + 1 + and current.patch == 0 + ): + return "minor" + if ( + current.major == previous.major + and current.minor == previous.minor + and current.patch == previous.patch + 1 + ): + return "patch" + raise ReleaseContractError( + f"release {previous_version} -> {version} is not one SemVer bump" + ) + + +def _metadata_from_wheel(path: Path) -> tuple[str, str]: + with zipfile.ZipFile(path) as archive: + metadata_names = [name for name in archive.namelist() if name.endswith(".dist-info/METADATA")] + if len(metadata_names) != 1: + raise ReleaseContractError(f"{path.name} must contain exactly one METADATA file") + metadata = Parser().parsestr(archive.read(metadata_names[0]).decode("utf-8")) + return metadata.get("Name", ""), metadata.get("Version", "") + + +def _metadata_from_sdist(path: Path) -> tuple[str, str]: + with tarfile.open(path, mode="r:gz") as archive: + package_info = [member for member in archive.getmembers() if member.name.endswith("/PKG-INFO")] + if len(package_info) != 1: + raise ReleaseContractError(f"{path.name} must contain exactly one PKG-INFO file") + extracted = archive.extractfile(package_info[0]) + if extracted is None: + raise ReleaseContractError(f"cannot read metadata from {path.name}") + metadata = Parser().parsestr(extracted.read().decode("utf-8")) + return metadata.get("Name", ""), metadata.get("Version", "") + + +def _artifact_records(artifact_dir: Path, version: str) -> list[dict[str, object]]: + artifact_dir = artifact_dir.resolve() + wheels = sorted(artifact_dir.glob("*.whl")) + sdists = sorted(artifact_dir.glob("*.tar.gz")) + if len(wheels) != 1 or len(sdists) != 1: + raise ReleaseContractError("dist must contain exactly one wheel and one sdist") + + records: list[dict[str, object]] = [] + for path, metadata_reader in ((wheels[0], _metadata_from_wheel), (sdists[0], _metadata_from_sdist)): + if not path.is_file() or path.is_symlink(): + raise ReleaseContractError(f"release artifact must be a regular file: {path.name}") + name, metadata_version = metadata_reader(path) + if name.lower().replace("_", "-") != PACKAGE_NAME: + raise ReleaseContractError(f"unexpected package name in {path.name}: {name!r}") + if metadata_version != version: + raise ReleaseContractError( + f"{path.name} metadata has version {metadata_version!r}; expected version {version}" + ) + records.append( + { + "filename": path.name, + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "size": path.stat().st_size, + } + ) + return sorted(records, key=lambda item: str(item["filename"])) + + +def _validate_sha(value: str, label: str) -> None: + if not SHA_PATTERN.fullmatch(value): + raise ReleaseContractError(f"{label} must be a lowercase 40-character Git SHA") + + +def create_release_receipt( + *, + previous_version: str, + version: str, + tag: str, + source_sha: str, + hermes_source_sha: str, + workflow: str, + run_id: str, + run_attempt: int, + artifact_dir: Path, + output_path: Path, +) -> dict[str, object]: + """Write the canonical receipt for the already-tested distribution files.""" + + release_class = _release_class(previous_version, version) + expected_tag = f"{TAG_PREFIX}{version}" + if tag != expected_tag: + raise ReleaseContractError(f"tag {tag!r} does not match version {version!r}") + _validate_sha(source_sha, "source_sha") + _validate_sha(hermes_source_sha, "hermes_source_sha") + if not workflow or not run_id or run_attempt < 1: + raise ReleaseContractError("workflow run evidence is incomplete") + + promotion = ( + {"automatic_candidate": False, "state": "manual_migration_required"} + if release_class == "major" + else {"automatic_candidate": True, "state": "automatic_candidate"} + ) + passed = {"result": "passed", "source_sha": source_sha, "status": "passed"} + receipt: dict[str, object] = { + "schema_version": 1, + "package": PACKAGE_NAME, + "previous_version": previous_version, + "version": version, + "tag": tag, + "source_sha": source_sha, + "release_class": release_class, + "promotion": promotion, + "registry_url": f"https://pypi.org/project/{PACKAGE_NAME}/{version}/", + "artifacts": _artifact_records(artifact_dir, version), + "evidence": { + "unit": {**passed, "command": "make test"}, + "public_contract": { + **passed, + "command": "make test-release (included in make test)", + }, + "hermes_contract": { + **passed, + "command": "make test-contract", + "hermes_source_sha": hermes_source_sha, + }, + "build_metadata": { + **passed, + "command": "make build && make check-dist", + }, + "workflow": { + "name": workflow, + "run_id": run_id, + "run_attempt": run_attempt, + "source_sha": source_sha, + }, + }, + } + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return receipt + + +def verify_release_receipt(receipt_path: Path, artifact_dir: Path) -> dict[str, object]: + """Recompute identity and hashes instead of trusting receipt assertions.""" + + try: + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ReleaseContractError(f"cannot read release receipt: {error}") from error + if not isinstance(receipt, dict) or receipt.get("schema_version") != 1: + raise ReleaseContractError("unsupported release receipt schema") + if receipt.get("package") != PACKAGE_NAME: + raise ReleaseContractError("release receipt package mismatch") + + version = receipt.get("version") + previous_version = receipt.get("previous_version") + source_sha = receipt.get("source_sha") + if not all(isinstance(value, str) for value in (version, previous_version, source_sha)): + raise ReleaseContractError("release receipt identity is incomplete") + assert isinstance(version, str) + assert isinstance(previous_version, str) + assert isinstance(source_sha, str) + _validate_sha(source_sha, "source_sha") + release_class = _release_class(previous_version, version) + if receipt.get("tag") != f"{TAG_PREFIX}{version}": + raise ReleaseContractError("release receipt tag mismatch") + if receipt.get("release_class") != release_class: + raise ReleaseContractError("release receipt class mismatch") + + expected_promotion = ( + {"automatic_candidate": False, "state": "manual_migration_required"} + if release_class == "major" + else {"automatic_candidate": True, "state": "automatic_candidate"} + ) + if receipt.get("promotion") != expected_promotion: + raise ReleaseContractError("release receipt promotion state mismatch") + + expected_artifacts = _artifact_records(artifact_dir, version) + actual_artifacts = receipt.get("artifacts") + if not isinstance(actual_artifacts, list): + raise ReleaseContractError("release receipt artifacts are missing") + actual_by_name = { + item.get("filename"): item + for item in actual_artifacts + if isinstance(item, dict) and isinstance(item.get("filename"), str) + } + for expected in expected_artifacts: + actual = actual_by_name.get(expected["filename"]) + if actual is None: + raise ReleaseContractError(f"missing artifact receipt for {expected['filename']}") + if actual.get("sha256") != expected["sha256"]: + raise ReleaseContractError(f"SHA-256 mismatch for {expected['filename']}") + if actual.get("size") != expected["size"]: + raise ReleaseContractError(f"size mismatch for {expected['filename']}") + if ( + len(actual_artifacts) != len(expected_artifacts) + or len(actual_by_name) != len(expected_artifacts) + ): + raise ReleaseContractError("release receipt contains unexpected artifacts") + + evidence = receipt.get("evidence") + if not isinstance(evidence, dict): + raise ReleaseContractError("release receipt evidence is missing") + for gate in ("unit", "public_contract", "hermes_contract", "build_metadata"): + item = evidence.get(gate) + if ( + not isinstance(item, dict) + or item.get("status") != "passed" + or item.get("result") != "passed" + or not isinstance(item.get("command"), str) + ): + raise ReleaseContractError(f"release receipt gate {gate} did not pass") + if item.get("source_sha") != source_sha: + raise ReleaseContractError(f"release receipt gate {gate} used another source") + hermes_sha = evidence["hermes_contract"].get("hermes_source_sha") + if not isinstance(hermes_sha, str): + raise ReleaseContractError("Hermes contract source SHA is missing") + _validate_sha(hermes_sha, "hermes_source_sha") + return receipt + + +def _fetch_json(url: str) -> dict[str, object]: + request = Request(url, headers={"User-Agent": "hermes-plugin-kit-release-verifier/1"}) + with urlopen(request, timeout=30) as response: + value = json.load(response) + if not isinstance(value, dict): + raise ReleaseContractError("PyPI returned a non-object release response") + return value + + +def _fetch_bytes(url: str) -> bytes: + request = Request(url, headers={"User-Agent": "hermes-plugin-kit-release-verifier/1"}) + with urlopen(request, timeout=60) as response: + return response.read() + + +def verify_pypi_release( + receipt_path: Path, + *, + fetch_json: Callable[[str], dict[str, object]] = _fetch_json, + fetch_bytes: Callable[[str], bytes] = _fetch_bytes, +) -> dict[str, object]: + """Verify PyPI exposes the exact receipt filenames and bytes.""" + + try: + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ReleaseContractError(f"cannot read release receipt: {error}") from error + if not isinstance(receipt, dict): + raise ReleaseContractError("release receipt must be an object") + version = receipt.get("version") + artifacts = receipt.get("artifacts") + if not isinstance(version, str) or not isinstance(artifacts, list): + raise ReleaseContractError("release receipt identity is incomplete") + SemVer.parse(version) + expected = { + item["filename"]: item["sha256"] + for item in artifacts + if isinstance(item, dict) + and isinstance(item.get("filename"), str) + and isinstance(item.get("sha256"), str) + } + if len(expected) != 2: + raise ReleaseContractError("release receipt must name one wheel and one sdist") + + metadata_url = f"https://pypi.org/pypi/{PACKAGE_NAME}/{version}/json" + try: + metadata = fetch_json(metadata_url) + except Exception as error: + raise ReleaseContractError(f"cannot fetch PyPI release metadata: {error}") from error + info = metadata.get("info") + urls = metadata.get("urls") + if not isinstance(info, dict) or info.get("version") != version: + raise ReleaseContractError("PyPI release version does not match the receipt") + if not isinstance(urls, list): + raise ReleaseContractError("PyPI release file inventory is missing") + published = { + item.get("filename"): item + for item in urls + if isinstance(item, dict) and isinstance(item.get("filename"), str) + } + if set(published) != set(expected): + raise ReleaseContractError("PyPI filenames do not match the release receipt") + + for filename, expected_sha in expected.items(): + item = published[filename] + digests = item.get("digests") + url = item.get("url") + if item.get("yanked") is True: + raise ReleaseContractError(f"PyPI artifact is yanked: {filename}") + if not isinstance(digests, dict) or digests.get("sha256") != expected_sha: + raise ReleaseContractError(f"PyPI SHA-256 mismatch for {filename}") + if not isinstance(url, str): + raise ReleaseContractError(f"PyPI artifact URL is missing for {filename}") + parsed_url = urlparse(url) + if parsed_url.scheme != "https" or parsed_url.hostname != "files.pythonhosted.org": + raise ReleaseContractError(f"unexpected PyPI artifact URL for {filename}") + try: + published_bytes = fetch_bytes(url) + except Exception as error: + raise ReleaseContractError(f"cannot download PyPI artifact {filename}: {error}") from error + if hashlib.sha256(published_bytes).hexdigest() != expected_sha: + raise ReleaseContractError(f"downloaded PyPI SHA-256 mismatch for {filename}") + return metadata + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + baseline = subparsers.add_parser("validate-baseline") + baseline.add_argument("--repository", type=Path, default=Path.cwd()) + + history = subparsers.add_parser("validate-history") + history.add_argument("--repository", type=Path, default=Path.cwd()) + history.add_argument("--baseline-tag", required=True) + + create = subparsers.add_parser("create-receipt") + create.add_argument("--previous-version", required=True) + create.add_argument("--version", required=True) + create.add_argument("--tag", required=True) + create.add_argument("--source-sha", required=True) + create.add_argument("--hermes-source-sha", required=True) + create.add_argument("--workflow", required=True) + create.add_argument("--run-id", required=True) + create.add_argument("--run-attempt", required=True, type=int) + create.add_argument("--artifact-dir", required=True, type=Path) + create.add_argument("--output", required=True, type=Path) + + verify = subparsers.add_parser("verify-receipt") + verify.add_argument("--receipt", required=True, type=Path) + verify.add_argument("--artifact-dir", required=True, type=Path) + + pypi = subparsers.add_parser("verify-pypi") + pypi.add_argument("--receipt", required=True, type=Path) + pypi.add_argument("--attempts", type=int, default=12) + pypi.add_argument("--delay-seconds", type=int, default=10) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parser().parse_args(argv) + if args.command == "validate-baseline": + baseline = validate_release_baseline(args.repository) + print(json.dumps(baseline.__dict__, sort_keys=True)) + return 0 + if args.command == "validate-history": + release_class = validate_conventional_history( + args.repository, args.baseline_tag + ) + print(json.dumps({"release_class": release_class}, sort_keys=True)) + return 0 + if args.command == "create-receipt": + create_release_receipt( + previous_version=args.previous_version, + version=args.version, + tag=args.tag, + source_sha=args.source_sha, + hermes_source_sha=args.hermes_source_sha, + workflow=args.workflow, + run_id=args.run_id, + run_attempt=args.run_attempt, + artifact_dir=args.artifact_dir, + output_path=args.output, + ) + return 0 + if args.command == "verify-receipt": + verify_release_receipt(args.receipt, args.artifact_dir) + return 0 + if args.attempts < 1 or args.delay_seconds < 0: + raise ReleaseContractError("PyPI retry settings must be non-negative") + for attempt in range(1, args.attempts + 1): + try: + verify_pypi_release(args.receipt) + return 0 + except ReleaseContractError: + if attempt == args.attempts: + raise + time.sleep(args.delay_seconds) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except ReleaseContractError as error: + raise SystemExit(f"release contract failed: {error}") from error diff --git a/tests/test_release_contract.py b/tests/test_release_contract.py new file mode 100644 index 0000000..4f3b24a --- /dev/null +++ b/tests/test_release_contract.py @@ -0,0 +1,407 @@ +from __future__ import annotations + +import hashlib +import json +import subprocess +import tarfile +import tempfile +import unittest +import zipfile +from pathlib import Path + +import yaml + +from scripts.release_contract import ( + ReleaseContractError, + classify_commit_messages, + create_release_receipt, + validate_conventional_history, + validate_release_baseline, + verify_pypi_release, + verify_release_receipt, +) + + +ROOT = Path(__file__).resolve().parents[1] + + +class ConventionalReleaseIntentTests(unittest.TestCase): + def test_fix_history_requests_patch_release(self) -> None: + self.assertEqual(classify_commit_messages(["fix: keep schemas nested"]), "patch") + + def test_feature_history_requests_minor_release(self) -> None: + self.assertEqual( + classify_commit_messages(["fix: keep schemas nested", "feat: add hook helper"]), + "minor", + ) + + def test_breaking_history_requests_major_release(self) -> None: + self.assertEqual( + classify_commit_messages( + ["feat!: replace the registration receipt contract"] + ), + "major", + ) + + def test_docs_only_history_does_not_request_release(self) -> None: + self.assertIsNone(classify_commit_messages(["docs: explain plugin ranges"])) + + def test_invalid_commit_fails_closed(self) -> None: + with self.assertRaisesRegex(ReleaseContractError, "invalid conventional commit"): + classify_commit_messages(["updated stuff"]) + + +class ReleaseBaselineTests(unittest.TestCase): + def _git(self, repo: Path, *args: str) -> str: + result = subprocess.run( + ["git", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + def _repository(self, *, tag: bool) -> tempfile.TemporaryDirectory[str]: + temporary = tempfile.TemporaryDirectory() + repo = Path(temporary.name) + self._git(repo, "init", "-q", "-b", "main") + self._git(repo, "config", "user.name", "Release Contract") + self._git(repo, "config", "user.email", "release-contract@example.invalid") + (repo / "pyproject.toml").write_text( + '[project]\nname = "hermes-plugin-kit"\nversion = "0.7.0"\n', + encoding="utf-8", + ) + self._git(repo, "add", "pyproject.toml") + self._git(repo, "commit", "-q", "-m", "chore: establish baseline") + if tag: + self._git(repo, "tag", "v0.7.0") + return temporary + + def test_missing_baseline_tag_is_rejected(self) -> None: + with self._repository(tag=False) as directory: + with self.assertRaisesRegex(ReleaseContractError, "baseline tag v0.7.0"): + validate_release_baseline(Path(directory)) + + def test_existing_version_tag_must_be_clean_and_ancestral(self) -> None: + with self._repository(tag=True) as directory: + repo = Path(directory) + (repo / "change.txt").write_text("feature\n", encoding="utf-8") + self._git(repo, "add", "change.txt") + self._git(repo, "commit", "-q", "-m", "feat: add a feature") + + baseline = validate_release_baseline(repo) + self.assertEqual(baseline.version, "0.7.0") + self.assertEqual(baseline.tag, "v0.7.0") + self.assertEqual(len(baseline.source_sha), 40) + + (repo / "dirty.txt").write_text("dirty\n", encoding="utf-8") + with self.assertRaisesRegex(ReleaseContractError, "working tree is dirty"): + validate_release_baseline(repo) + + def test_invalid_non_merge_commit_in_history_is_rejected(self) -> None: + with self._repository(tag=True) as directory: + repo = Path(directory) + (repo / "change.txt").write_text("invalid\n", encoding="utf-8") + self._git(repo, "add", "change.txt") + self._git(repo, "commit", "-q", "-m", "updated stuff") + + with self.assertRaisesRegex(ReleaseContractError, "invalid conventional commit"): + validate_conventional_history(repo, "v0.7.0") + + def test_valid_history_returns_strongest_release_intent(self) -> None: + with self._repository(tag=True) as directory: + repo = Path(directory) + (repo / "change.txt").write_text("feature\n", encoding="utf-8") + self._git(repo, "add", "change.txt") + self._git(repo, "commit", "-q", "-m", "feat: add a feature") + (repo / "change.txt").write_text("fix\n", encoding="utf-8") + self._git(repo, "add", "change.txt") + self._git(repo, "commit", "-q", "-m", "fix: correct the feature") + + self.assertEqual(validate_conventional_history(repo, "v0.7.0"), "minor") + + +class ReleaseReceiptTests(unittest.TestCase): + VERSION = "0.8.0" + SOURCE_SHA = "a" * 40 + HERMES_SHA = "b" * 40 + + def _write_dist(self, directory: Path, version: str = VERSION) -> None: + dist_info = f"hermes_plugin_kit-{version}.dist-info" + metadata = ( + "Metadata-Version: 2.4\n" + "Name: hermes-plugin-kit\n" + f"Version: {version}\n\n" + ) + wheel = directory / f"hermes_plugin_kit-{version}-py3-none-any.whl" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr(f"{dist_info}/METADATA", metadata) + + source_root = f"hermes_plugin_kit-{version}" + package_info = directory / "PKG-INFO" + package_info.write_text(metadata, encoding="utf-8") + sdist = directory / f"{source_root}.tar.gz" + with tarfile.open(sdist, "w:gz") as archive: + archive.add(package_info, arcname=f"{source_root}/PKG-INFO") + package_info.unlink() + + def _create(self, dist: Path, output: Path, *, previous: str = "0.7.0") -> dict: + return create_release_receipt( + previous_version=previous, + version=self.VERSION, + tag=f"v{self.VERSION}", + source_sha=self.SOURCE_SHA, + hermes_source_sha=self.HERMES_SHA, + workflow="release.yml", + run_id="12345", + run_attempt=1, + artifact_dir=dist, + output_path=output, + ) + + def test_receipt_binds_artifact_metadata_and_hashes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + dist = root / "dist" + dist.mkdir() + self._write_dist(dist) + + receipt = self._create(dist, root / "release-receipt.json") + + self.assertEqual(receipt["version"], self.VERSION) + self.assertEqual(receipt["tag"], f"v{self.VERSION}") + self.assertEqual(receipt["source_sha"], self.SOURCE_SHA) + self.assertEqual(receipt["release_class"], "minor") + self.assertEqual( + receipt["promotion"], + {"automatic_candidate": True, "state": "automatic_candidate"}, + ) + self.assertEqual( + [artifact["filename"] for artifact in receipt["artifacts"]], + [ + f"hermes_plugin_kit-{self.VERSION}-py3-none-any.whl", + f"hermes_plugin_kit-{self.VERSION}.tar.gz", + ], + ) + for artifact in receipt["artifacts"]: + expected = hashlib.sha256( + (dist / artifact["filename"]).read_bytes() + ).hexdigest() + self.assertEqual(artifact["sha256"], expected) + self.assertEqual( + receipt["evidence"]["hermes_contract"]["hermes_source_sha"], + self.HERMES_SHA, + ) + for gate in ("unit", "public_contract", "hermes_contract", "build_metadata"): + self.assertEqual(receipt["evidence"][gate]["source_sha"], self.SOURCE_SHA) + self.assertEqual(receipt["evidence"][gate]["result"], "passed") + self.assertTrue(receipt["evidence"][gate]["command"]) + self.assertEqual(verify_release_receipt(root / "release-receipt.json", dist), receipt) + + def test_hash_mismatch_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + dist = root / "dist" + dist.mkdir() + self._write_dist(dist) + receipt_path = root / "release-receipt.json" + receipt = self._create(dist, receipt_path) + artifact = dist / receipt["artifacts"][0]["filename"] + artifact.write_bytes(artifact.read_bytes() + b"tampered") + + with self.assertRaisesRegex(ReleaseContractError, "SHA-256 mismatch"): + verify_release_receipt(receipt_path, dist) + + def test_major_receipt_is_never_automatic_candidate(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + dist = root / "dist" + dist.mkdir() + self._write_dist(dist, version="1.0.0") + + receipt = create_release_receipt( + previous_version="0.7.0", + version="1.0.0", + tag="v1.0.0", + source_sha=self.SOURCE_SHA, + hermes_source_sha=self.HERMES_SHA, + workflow="release.yml", + run_id="12345", + run_attempt=1, + artifact_dir=dist, + output_path=root / "release-receipt.json", + ) + + self.assertEqual(receipt["release_class"], "major") + self.assertEqual( + receipt["promotion"], + { + "automatic_candidate": False, + "state": "manual_migration_required", + }, + ) + + def test_package_metadata_version_must_match_release(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + dist = root / "dist" + dist.mkdir() + self._write_dist(dist, version="0.7.1") + + with self.assertRaisesRegex(ReleaseContractError, "expected version 0.8.0"): + self._create(dist, root / "release-receipt.json") + + def test_pypi_inventory_and_downloaded_bytes_match_receipt(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + dist = root / "dist" + dist.mkdir() + self._write_dist(dist) + receipt_path = root / "release-receipt.json" + receipt = self._create(dist, receipt_path) + urls = [] + payloads = {} + for artifact in receipt["artifacts"]: + filename = artifact["filename"] + url = f"https://files.pythonhosted.org/packages/release/{filename}" + payloads[url] = (dist / filename).read_bytes() + urls.append( + { + "filename": filename, + "digests": {"sha256": artifact["sha256"]}, + "url": url, + "yanked": False, + } + ) + metadata = {"info": {"version": self.VERSION}, "urls": urls} + + result = verify_pypi_release( + receipt_path, + fetch_json=lambda _url: metadata, + fetch_bytes=payloads.__getitem__, + ) + + self.assertEqual(result, metadata) + + def test_pypi_download_hash_mismatch_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + dist = root / "dist" + dist.mkdir() + self._write_dist(dist) + receipt_path = root / "release-receipt.json" + receipt = self._create(dist, receipt_path) + urls = [ + { + "filename": artifact["filename"], + "digests": {"sha256": artifact["sha256"]}, + "url": f"https://files.pythonhosted.org/packages/release/{artifact['filename']}", + "yanked": False, + } + for artifact in receipt["artifacts"] + ] + + with self.assertRaisesRegex( + ReleaseContractError, "downloaded PyPI SHA-256 mismatch" + ): + verify_pypi_release( + receipt_path, + fetch_json=lambda _url: { + "info": {"version": self.VERSION}, + "urls": urls, + }, + fetch_bytes=lambda _url: b"tampered", + ) + + +class ReleaseWorkflowContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.workflow = (ROOT / ".github/workflows/release.yml").read_text( + encoding="utf-8" + ) + cls.test_workflow = (ROOT / ".github/workflows/test.yml").read_text( + encoding="utf-8" + ) + cls.release_config = yaml.load(cls.workflow, Loader=yaml.BaseLoader) + + def test_release_is_materialized_without_push_or_vcs_release(self) -> None: + self.assertIn("semantic-release --strict version --no-push --no-vcs-release", self.workflow) + self.assertIn("release-source.bundle", self.workflow) + self.assertIn("git push --atomic origin", self.workflow) + + def test_exact_release_source_is_tested_and_built_once(self) -> None: + self.assertIn('test "$(git rev-parse HEAD)" = "$RELEASE_SHA"', self.workflow) + self.assertEqual(self.workflow.count("make build"), 1) + source_push = self.workflow.index("git push --atomic origin") + self.assertLess(self.workflow.index("make test"), source_push) + self.assertLess(self.workflow.index("make build"), source_push) + + def test_pypi_publish_precedes_discoverable_github_release_receipt(self) -> None: + publish = self.workflow.index("pypa/gh-action-pypi-publish@") + github_release = self.workflow.index("gh release create") + self.assertLess(publish, github_release) + self.assertIn("name: pypi", self.workflow) + self.assertIn("id-token: write", self.workflow) + self.assertNotIn("dispatches", self.workflow) + github_release_job = self.release_config["jobs"]["github-release"] + self.assertIn("verify-pypi", github_release_job["needs"]) + + def test_pypi_is_verified_from_registry_before_release_is_discoverable(self) -> None: + verify = self.release_config["jobs"]["verify-pypi"] + self.assertIn("publish", verify["needs"]) + self.assertIn("verify-pypi", self.workflow) + self.assertIn("downloaded bytes", self.workflow) + + def test_publishing_job_has_only_oidc_write_permission(self) -> None: + publish = self.release_config["jobs"]["publish"] + self.assertEqual(publish["permissions"], {"id-token": "write"}) + self.assertEqual(publish["environment"]["name"], "pypi") + self.assertEqual(publish["needs"], "promote-source") + + def test_invalid_history_is_strict_and_activation_is_fail_closed(self) -> None: + self.assertIn( + "semantic-release --strict version --no-push --no-vcs-release", + self.workflow, + ) + self.assertIn("SEMANTIC_RELEASE_ENABLED", self.workflow) + self.assertIn("validate-history", self.workflow) + self.assertIn('"$version" = "$previous_version"', self.workflow) + self.assertIn("Reject an already-published PyPI version", self.workflow) + self.assertIn("404) ;;", self.workflow) + + def test_publish_retry_never_rebuilds_and_must_reverify_registry_bytes(self) -> None: + publish = self.release_config["jobs"]["publish"] + publish_action = publish["steps"][-1] + self.assertEqual(publish_action["with"]["skip-existing"], "true") + self.assertEqual(self.workflow.count("make build"), 1) + self.assertIn("verify-pypi", self.release_config["jobs"]) + + def test_actions_are_immutable_sha_pinned(self) -> None: + action_lines = [ + line.strip() for line in self.workflow.splitlines() if "uses:" in line + ] + self.assertTrue(action_lines) + for line in action_lines: + with self.subTest(line=line): + self.assertRegex(line, r"uses: [^@]+@[0-9a-f]{40}\s+#\s+\S+") + + def test_test_workflow_actions_are_immutable_sha_pinned(self) -> None: + action_lines = [ + line.strip() for line in self.test_workflow.splitlines() if "uses:" in line + ] + self.assertTrue(action_lines) + for line in action_lines: + with self.subTest(line=line): + self.assertRegex(line, r"uses: [^@]+@[0-9a-f]{40}\s+#\s+\S+") + config = yaml.load(self.test_workflow, Loader=yaml.BaseLoader) + self.assertEqual(config["permissions"], {"contents": "read"}) + + def test_unsafe_pull_request_target_is_not_used(self) -> None: + self.assertNotIn("pull_request_target", self.workflow) + self.assertNotIn("pull_request_target", self.test_workflow) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 2322f6a..95da3d5 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "anyio" version = "4.14.2" @@ -15,6 +24,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -24,6 +42,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.9" @@ -98,6 +165,135 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, ] +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "click-option-group" +version = "0.5.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/ff/d291d66595b30b83d1cb9e314b2c9be7cfc7327d4a0d40a15da2416ea97b/click_option_group-0.5.9.tar.gz", hash = "sha256:f94ed2bc4cf69052e0f29592bd1e771a1789bd7bfc482dd0bc482134aff95823", size = 22222, upload-time = "2025-10-09T09:38:01.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/45/54bb2d8d4138964a94bef6e9afe48b0be4705ba66ac442ae7d8a8dc4ffef/click_option_group-0.5.9-py3-none-any.whl", hash = "sha256:ad2599248bd373e2e19bec5407967c3eec1d0d4fc4a5e77b08a0481e75991080", size = 11553, upload-time = "2025-10-09T09:38:00.066Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "docutils" +version = "0.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz", hash = "sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e", size = 2303823, upload-time = "2026-05-27T17:41:06.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl", hash = "sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea", size = 634701, upload-time = "2026-05-27T17:40:58.442Z" }, +] + +[[package]] +name = "dotty-dict" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/ab/88d67f02024700b48cd8232579ad1316aa9df2272c63049c27cc094229d6/dotty_dict-1.3.1.tar.gz", hash = "sha256:4b016e03b8ae265539757a53eba24b9bfda506fb94fbce0bee843c6f05541a15", size = 7699, upload-time = "2022-07-09T18:50:57.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/91/e0d457ee03ec33d79ee2cd8d212debb1bc21dfb99728ae35efdb5832dc22/dotty_dict-1.3.1-py3-none-any.whl", hash = "sha256:5022d234d9922f13aa711b4950372a06a6d64cb6d6db9ba43d0ba133ebfce31f", size = 7014, upload-time = "2022-07-09T18:50:55.058Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.59" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/dc/126b28e76b24a9268ba931ad3e012f71ebdadf62fd9f17758f7074bb0b20/gitpython-3.1.59.tar.gz", hash = "sha256:0a1475cfdc38a5bfba1a3e9a4a9da52a39749ecec322b772915c019f94e5b7e4", size = 230445, upload-time = "2026-08-10T12:03:20.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl", hash = "sha256:67a82f537384578643624c8b2c531938a9b82be431663e575dcf638526631d4c", size = 220996, upload-time = "2026-08-10T12:03:18.804Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -118,7 +314,9 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "httpx", extra = ["socks"] }, + { name = "python-semantic-release" }, { name = "requests" }, + { name = "twine" }, ] [package.metadata] @@ -127,7 +325,9 @@ requires-dist = [{ name = "pyyaml", specifier = ">=6" }] [package.metadata.requires-dev] dev = [ { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, + { name = "python-semantic-release", specifier = "==10.6.1" }, { name = "requests", specifier = "==2.33.0" }, + { name = "twine", specifier = "==6.2.0" }, ] [[package]] @@ -163,6 +363,18 @@ socks = [ { name = "socksio" }, ] +[[package]] +name = "id" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069", size = 18088, upload-time = "2026-02-04T16:19:41.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl", hash = "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca", size = 14689, upload-time = "2026-02-04T16:19:40.051Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -172,6 +384,430 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "importlib-resources" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "nh3" +version = "0.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/1b/ef84624f14954d270f74060a19fc550dd4f06656399447569afb584d8c06/nh3-0.3.6.tar.gz", hash = "sha256:f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7", size = 24684, upload-time = "2026-06-22T00:47:02.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/3e/6506aa4f23dc7b7993a2d0a45dca3ce864ec48380adfe15a173e643c63e8/nh3-0.3.6-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:2411e8c3cee81a1ddd62c2a5d50585c28aa5566d373ad1db92536b95ddb24ef2", size = 1421679, upload-time = "2026-06-22T00:46:20.248Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e1/e96e7864a7a53bd6b6fab7e9632467382a2a2c1f3fed951918ad131542fb/nh3-0.3.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e196fa70c2ff2eb4de7d3df3108f8f358c1d69dff20d45b11f20a5aa227ffb6d", size = 792570, upload-time = "2026-06-22T00:46:22.179Z" }, + { url = "https://files.pythonhosted.org/packages/59/62/5b6108bedaef2b2637fed04c87bdbcb5967b9961758b41f0e466ef22a022/nh3-0.3.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:34d2b0d934156b87ee114f599a3ba9b8b9e17b5d79652ba3a13fa50903de965e", size = 842243, upload-time = "2026-06-22T00:46:23.801Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4a/526f199626bfcb496bc01a268051b44737962005553b158e985ed7e64865/nh3-0.3.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2f14b7ae1fca99c4a66c981aac3974e7fbc1ca30a12673d223ae1df76680917", size = 1001468, upload-time = "2026-06-22T00:46:25.481Z" }, + { url = "https://files.pythonhosted.org/packages/49/09/0d8e3101636d9ad88cdefb2914e764cb8e876ebdbb4286bfc251277d9c67/nh3-0.3.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:889932a97fb4abb6f95fef1914c0d269ebfb60011e67121c1163059b9449dbb4", size = 1082933, upload-time = "2026-06-22T00:46:27.15Z" }, + { url = "https://files.pythonhosted.org/packages/09/a1/ea83abe738a3fbaa203dfdb836ca7cbab0e7e9609faaee4fe1d4652599c0/nh3-0.3.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:edb2b4a1a27523e6cc7c417f8d21ce3d005243548b93e56b762b66b0c7f589f9", size = 1043120, upload-time = "2026-06-22T00:46:28.89Z" }, + { url = "https://files.pythonhosted.org/packages/66/69/0654482b8635012fbae67826bd6c381abb05d841ac7388b9b4666300fdad/nh3-0.3.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43bc1ed3fa0716295fabee29ba42b2667e4a51d140b0a68e092170a765474fa6", size = 1023824, upload-time = "2026-06-22T00:46:30.453Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/1f7285ffadc8307c4dbeb08d21b920536d5117785056d1079e998c4dfa44/nh3-0.3.6-cp314-cp314t-win32.whl", hash = "sha256:597a8e843bea00b2eb5520658dc24a9bb032e7fc9e7c2c0c4cd29420220c9796", size = 599253, upload-time = "2026-06-22T00:46:32.072Z" }, + { url = "https://files.pythonhosted.org/packages/36/ea/5542f3c45da4c00290d9d67a65e996702e23e613c4b627de3e09cb9fe357/nh3-0.3.6-cp314-cp314t-win_amd64.whl", hash = "sha256:4713502748f564fee0633b37b3403783ce0a3af3a3d148ad91025a5bdadb7bc6", size = 612553, upload-time = "2026-06-22T00:46:33.53Z" }, + { url = "https://files.pythonhosted.org/packages/66/35/26bd47e6af5915a628281dccdac354ddf4e32f7397047894270acd8c9870/nh3-0.3.6-cp314-cp314t-win_arm64.whl", hash = "sha256:69bbb92865a693d909db3a700d3c01537533844d0948c1e9323561ce06ecda41", size = 595151, upload-time = "2026-06-22T00:46:34.878Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ab/a7653bce9a3b204be6a6931767a9e23595807bb84790ce6685e4d7e5bd08/nh3-0.3.6-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a43ebd7543555c3ac1bc353023d0794e75cb76f6f18f19c32e95441496c0cc25", size = 1443564, upload-time = "2026-06-22T00:46:36.66Z" }, + { url = "https://files.pythonhosted.org/packages/41/21/e1084ab18eb589506335c7c7576f2d4643e9a0c0e33983ef0e549a256b96/nh3-0.3.6-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1b160831c9cdb06a6c79c2f9cdb11386602938f9af260d1c457a85add4f6f69", size = 838002, upload-time = "2026-06-22T00:46:38.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/94/f48d08e6f72a406300fa11d8acd929fea1a80d4bf750fa292cb10785f126/nh3-0.3.6-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d14bf7982e7a77c0c775634c29c07ce08b38a046df73e1c1f139b3e82f18a38e", size = 823045, upload-time = "2026-06-22T00:46:39.495Z" }, + { url = "https://files.pythonhosted.org/packages/25/bb/431615ba1d1d3eb63cde0f974f2114edf863a8a3f6049a12fed23fc241d3/nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:44673b27010051ab5a5e438a86ec31bbda61d4a77d7e900af6b7be3037c1abae", size = 1093171, upload-time = "2026-06-22T00:46:41.21Z" }, + { url = "https://files.pythonhosted.org/packages/0e/24/a0d80182a18919665fefd19c1c06f1d1df1c9a6455d0252de40c034a0bc3/nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6b7beece07525dc6e6b0fc2f104442de2ba328360ad00e50cbe2e1fd620447d", size = 1049217, upload-time = "2026-06-22T00:46:42.804Z" }, + { url = "https://files.pythonhosted.org/packages/0a/13/6f1e302ca674ac74362e150848ad56a1be5145391204f74facdb8e94df12/nh3-0.3.6-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:455469a29951edc92bc48b47ac2281c3f2609e6c4f6a047056449f8c2c23facf", size = 917372, upload-time = "2026-06-22T00:46:44.495Z" }, + { url = "https://files.pythonhosted.org/packages/5b/67/314f6151bad77a93d751978a344033e1fc890822f05f0416079338e34231/nh3-0.3.6-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:905f877dc66dd7aea4a76e54bcb26acb5ff8216f720c0017ccf63e0e6035698e", size = 806699, upload-time = "2026-06-22T00:46:45.99Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a6/bfaa00046e58603507dcfc266c4778e3ab7adf68a5dedd73b6274b8d9314/nh3-0.3.6-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:25c733bee928530556b1db0ea46c52cf5aa686146e38e60a6fc7cb801ef91cec", size = 835165, upload-time = "2026-06-22T00:46:47.617Z" }, + { url = "https://files.pythonhosted.org/packages/30/a8/fb2c38845efb703a9173bffdfc745fc64d2b0e55cfc73a3647d2f028250c/nh3-0.3.6-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2f90d9a0cfdbee218994fdaaeeb5a0fde62d08f35e4eef0378ec1e2200172fd0", size = 858282, upload-time = "2026-06-22T00:46:49.276Z" }, + { url = "https://files.pythonhosted.org/packages/68/17/06e72a18ee9b572914447338237ca7eb164c0df901f141bc10d1282247a2/nh3-0.3.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:82ca5bf427ad1b216b65ede1a2e2d87dc49bec417ceba0f297213107d3cd9d78", size = 1014328, upload-time = "2026-06-22T00:46:51.026Z" }, + { url = "https://files.pythonhosted.org/packages/11/f9/3966c61455668c08853bf5e33b4bed93c421f3194ce4de896dc248d6f6ce/nh3-0.3.6-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438", size = 1098207, upload-time = "2026-06-22T00:46:52.674Z" }, + { url = "https://files.pythonhosted.org/packages/19/d3/479cb4ae440424825735d60525b53e3c77fd60fd6e6afc0e984f00eb0178/nh3-0.3.6-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:082675ff87b9385ec430ffe6d5847ba7456cc39b73720cd4add472f9f4cffd56", size = 1056961, upload-time = "2026-06-22T00:46:54.335Z" }, + { url = "https://files.pythonhosted.org/packages/17/0c/6cdb5ee1e127be50dc8391e54bddc1f64e87bf4bfad0c55633320e2e02db/nh3-0.3.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36d06341bd501240d320f5942481ed5e6846136b666e1ba4faf802b78ebc875f", size = 1033829, upload-time = "2026-06-22T00:46:56.258Z" }, + { url = "https://files.pythonhosted.org/packages/e9/55/9de666ad975d6ccd77d799ea0add55ee2347aa81286ce21b2a97c070746b/nh3-0.3.6-cp38-abi3-win32.whl", hash = "sha256:5276ef17bdba9ad8040575c74072008b13aae429436e9d0429e718bb5f90f4da", size = 609081, upload-time = "2026-06-22T00:46:57.665Z" }, + { url = "https://files.pythonhosted.org/packages/82/fa/2b5d684e3edf1e81bfd02d298c78c3e3da77ca1d8a2be3183a79544a7548/nh3-0.3.6-cp38-abi3-win_amd64.whl", hash = "sha256:f338ac7d594c067679f1e99b4f5ec3906842979560f9d8f15d6bdfa39a353b10", size = 624461, upload-time = "2026-06-22T00:46:59.163Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e5/7cafee2f0413ca4cb0ef3bd111e94d408a48810008b283ad8aee00dd1809/nh3-0.3.6-cp38-abi3-win_arm64.whl", hash = "sha256:69f365963f63a1e9bff53bdbb3c542c7c2efed3e163c9d5d83a772a2ac468c21", size = 603060, upload-time = "2026-06-22T00:47:00.596Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "python-gitlab" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "requests-toolbelt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/55/8050293b360a29218a15892c2b936e286a69fd4f5d2cf54c7cbe19d34b47/python_gitlab-8.5.0.tar.gz", hash = "sha256:628529ec4ce1f9a7ba2c145b2cf5e4eeca3015418e504b2e6fba70171b6b1b59", size = 411497, upload-time = "2026-07-28T02:04:30.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/3d/fb547ed2ac318132517e001b74b09d1f17d2c2c681cad1e32a8b84139867/python_gitlab-8.5.0-py3-none-any.whl", hash = "sha256:94228973c54f09eccd30f5160eca91200adc31d6ed0c894221a3865b90f96426", size = 148234, upload-time = "2026-07-28T02:04:28.454Z" }, +] + +[[package]] +name = "python-semantic-release" +version = "10.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "click-option-group" }, + { name = "deprecated" }, + { name = "dotty-dict" }, + { name = "gitpython" }, + { name = "importlib-resources" }, + { name = "jinja2" }, + { name = "pydantic" }, + { name = "python-gitlab" }, + { name = "requests" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/f6/06d5aa54b46bb192b00ef3e74234300ea5932dd310d142a3c8070e770e93/python_semantic_release-10.6.1.tar.gz", hash = "sha256:ee6369238f72e75a009b3724481232c8b813416191be099bc0266e375fd02b2b", size = 626453, upload-time = "2026-07-06T06:14:35.507Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/97/be812cd1eb350551d2f3cc38426864cce493a03ed31f4749133bcff8d053/python_semantic_release-10.6.1-py3-none-any.whl", hash = "sha256:36f7319515f218719d0972bc9535813a930f04cd19556767599e9863242efcdc", size = 155674, upload-time = "2026-07-06T06:14:33.575Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -227,6 +863,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "readme-renderer" +version = "45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "nh3" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/51/d3a6ea424652c60f05600d8c2e01a55c913755e7cdad64afabbd1aa16f44/readme_renderer-45.0.tar.gz", hash = "sha256:030a8fac74904f8fba11ad1bb6964e3f76e896dc7e5e71f16af190c9056696d1", size = 36172, upload-time = "2026-06-09T21:05:17.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/1b/295bf2fa3e740131778065e5ffa2c481f0e7210182d408e9a2c244ff5b0c/readme_renderer-45.0-py3-none-any.whl", hash = "sha256:3385ed220117104a2bceb4a9dac8c5fdf6d1f96890d7ea2a9c7174fd5c84091f", size = 14134, upload-time = "2026-06-09T21:05:15.85Z" }, +] + [[package]] name = "requests" version = "2.33.0" @@ -242,6 +892,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, ] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rfc3986" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2022-01-10T00:52:30.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" }, +] + +[[package]] +name = "rich" +version = "14.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + [[package]] name = "socksio" version = "1.0.0" @@ -251,6 +966,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, ] +[[package]] +name = "tomlkit" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, +] + +[[package]] +name = "twine" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "id" }, + { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "packaging" }, + { name = "readme-renderer" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "rfc3986" }, + { name = "rich" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/a8/949edebe3a82774c1ec34f637f5dd82d1cf22c25e963b7d63771083bbee5/twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf", size = 172262, upload-time = "2025-09-04T15:43:17.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -260,6 +1004,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" @@ -268,3 +1024,87 @@ sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] + +[[package]] +name = "wrapt" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/b0/c1f5a970721f06b85c0cd5142e0ff8fe067708abd779b0c4f4be7d61d09f/wrapt-2.3.0.tar.gz", hash = "sha256:681a2d0eefd721998f90642762b8e75c2159ec531b20ad5e437245ea7b06a107", size = 131509, upload-time = "2026-07-28T06:06:14.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b8/9182e4c618a847be0baccb68e4602b070d0fa22c782cf058f4bc66b32709/wrapt-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ab559e1b2551d23d54db2a0001c6d73bad022a254639561c5f6c382a9d6c2fe", size = 81427, upload-time = "2026-07-28T06:04:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/613cefd9c5977366b1587e61c0b428176d382e6d75b454084c5e58503042/wrapt-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bff9a671bc00709cab5a7f745c592b5671873449db0ee2a569af994f16b29a4d", size = 82360, upload-time = "2026-07-28T06:04:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/71/71/4cd2151a236f44a6e2dd4ed8011838d7ba0be3d656c8bafdfc65a2ed1917/wrapt-2.3.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fc648a335d7e01adb3640b25f02fd0ea05886cf04d0af7f4ee902bc7b5e466e8", size = 161700, upload-time = "2026-07-28T06:04:22.723Z" }, + { url = "https://files.pythonhosted.org/packages/49/2c/bc508fee75eb2919ed69769800b09968e4aab16897f909a23f39c81e323f/wrapt-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0077f3d65541925fa83002f967b22ad6550d24813ac64cb905f717194128d9c", size = 162922, upload-time = "2026-07-28T06:04:24.177Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e5/04f34d38e66d857dfc2fc4088d60e70c0e422467822defa49b2b4a26e17b/wrapt-2.3.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9790ea25190a4e0fe4cdf4eeb868e9d75f8a024a70a5b6bf9c348a3a2b72e731", size = 156125, upload-time = "2026-07-28T06:04:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/23/41/c35940ea1c423f129ebe4361db853bc80d4def6326242e1206fa15bf94f4/wrapt-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:816877aa749253149f9ecfd2635d4d948ecfa338e1a0311d187b1acb1bb8a3eb", size = 162039, upload-time = "2026-07-28T06:04:27.154Z" }, + { url = "https://files.pythonhosted.org/packages/0e/60/9bda34c3d7d182aa703fe35339ae0ed4c4dad5e5c587f93890143e1f87fb/wrapt-2.3.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d1c2c1b808600d2ea808e6360910a60ed5f409a4011655e10f9164ba0a414a6", size = 155110, upload-time = "2026-07-28T06:04:28.497Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ba/60bfd9b1a751f4fcb2d603668fc272d651ccdd339a56acf8c40ad21a0293/wrapt-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5ba1e5e08ddc46130e9682b2c249f2d1dd39bda9106ed4bd401b7519f18f41bd", size = 161089, upload-time = "2026-07-28T06:04:29.959Z" }, + { url = "https://files.pythonhosted.org/packages/0f/32/2bd358c6f4f1305c813479d1e9ba746bebdd794f4a20107ab2b3ee0cbd45/wrapt-2.3.0-cp311-cp311-win32.whl", hash = "sha256:45c9279b373d15649dfa2c2077cb3408ea1a6d3125afbdab9d6b809a66f68e14", size = 78030, upload-time = "2026-07-28T06:04:31.241Z" }, + { url = "https://files.pythonhosted.org/packages/4a/62/ecc969b13b141fef89b888c9760821cb01a86ac8fc953911592c8e1e1522/wrapt-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:195b1842b4122fb54e3cd3dd5b2b4aa49302a5a61da901df0481f5c97aedde84", size = 80944, upload-time = "2026-07-28T06:04:32.655Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3d/9278ada8a2b3f24372b630361e84e9a7de7abc3784634860c26d1c37785a/wrapt-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:6db604ef0c67bdb2042ecdfd7b7f037cf09733557ca42360d1018285634f7b98", size = 80074, upload-time = "2026-07-28T06:04:33.811Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4a/d17a0fad1bf1c5f2c887ff71fef75654141b0880bff71d157d955b5bec3a/wrapt-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0a45ffae742ce91a16e11cb6c7cd71e7f9994f3cbd283b962ab093f5c6dcf525", size = 82139, upload-time = "2026-07-28T06:04:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/6e/55/51b92daaf6defb57f4dc56bdcce985400f75c6984a03ca5e78ccac717028/wrapt-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:69e477046f2237ef0bc6547544ee73008dc764ca26eff44f09e976d221b34d5d", size = 82723, upload-time = "2026-07-28T06:04:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/28/7f/cfd9bc4b1f5e424eeea83d0493e43f3b1b02707ce8e50c47945873982bd5/wrapt-2.3.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d221a6e6ddd302b8397433184e96b59f259f50024b854db1c411a881586b6b8", size = 172381, upload-time = "2026-07-28T06:04:37.674Z" }, + { url = "https://files.pythonhosted.org/packages/cb/89/ff7814f6eb6856b479946117d1138a2fbb46cdb6b1f379db359056c69743/wrapt-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:392158c9a7f2ab1b8699418bfc0fe6f83548788c418b27d7bf2019ad3405cebb", size = 174120, upload-time = "2026-07-28T06:04:38.987Z" }, + { url = "https://files.pythonhosted.org/packages/12/1e/8eded8615d39e3ce81f626937a3a87b280a2a86239a2bf14a4b4bb345034/wrapt-2.3.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e5301c35cf75655eb33498f2bd6ae8703ca19940e3167dc9cdf740c712a39c60", size = 163035, upload-time = "2026-07-28T06:04:40.361Z" }, + { url = "https://files.pythonhosted.org/packages/35/ea/a0af2d9da62897af2a055484920de05dade30d2ba2c0d65cbdea875d3d8b/wrapt-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:418f54bb09d1762db02c7009b4051149893af3153a87f92d70356703c11eea02", size = 171887, upload-time = "2026-07-28T06:04:41.614Z" }, + { url = "https://files.pythonhosted.org/packages/7e/dd/63cd4c864c65ef4906df64bd2d378f4a62b54f28063f282dfb3bf93caead/wrapt-2.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1598becd30f8f2777d18564064eb4f4dbe1ab0e05a8f09786d0ef505ac782bf3", size = 161113, upload-time = "2026-07-28T06:04:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ee/82f1fc9e431b5c2c5a6d201aa865dbeae3984c311c6d11a185f0c8367cf6/wrapt-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3da470536bf9645143323dd41b32db55c6f4304ad382094c1a1da8a92061e10d", size = 170530, upload-time = "2026-07-28T06:04:44.212Z" }, + { url = "https://files.pythonhosted.org/packages/37/a5/5dc590e863a419930d988f8b7ca3e75a6befcfb10b6003b3a152f3d5f732/wrapt-2.3.0-cp312-cp312-win32.whl", hash = "sha256:fb8e2e6704a1e0b1b989546c69e2688371ef4a07fa5f61bde3eb6211186f5ac1", size = 78323, upload-time = "2026-07-28T06:04:45.484Z" }, + { url = "https://files.pythonhosted.org/packages/51/f9/4a6925a07951df56394f7e6ebe14f69f1c5ef9d87aa63e0839acf15aa63a/wrapt-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:cdc021cb0b62471d6aac7f2bd92f3b4658073775f9ee7fcd325c511129e7bcc8", size = 81180, upload-time = "2026-07-28T06:04:47.021Z" }, + { url = "https://files.pythonhosted.org/packages/a8/4f/8b5de0395b2a72216751d41c9861df6facaeb611b619d8810ed2b3b23eb2/wrapt-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:67bfe2485f50368c3fcd2275fc1fd100e350d601e0058921a7c82678a465aeab", size = 80155, upload-time = "2026-07-28T06:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6e/0f88a072483e76b881e3fdcd6b6ffb4a5791002514fe541e72b1b73c859a/wrapt-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d3fb71e65b001adfc42684522eeccd9c21d8ba679945abc993439567b66e59f", size = 81960, upload-time = "2026-07-28T06:04:49.622Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ff/b7e2776e7c294075eb712cc9ef573d1b818f393006d09787262b8fc871c4/wrapt-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51a7a4181c1295774812271fbcd7c909df372bc25579d4ed9eb875caaf0ae86f", size = 82435, upload-time = "2026-07-28T06:04:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/d8/90/343bb5d0f1f9669bc252a6073f085b4abf862511bd5c9c9eaec754341f1d/wrapt-2.3.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9045917809c63fdf7abe3a2ceaed3d670b8ee4500ddd9291192d30aeb34467c5", size = 170350, upload-time = "2026-07-28T06:04:52.187Z" }, + { url = "https://files.pythonhosted.org/packages/59/f8/13b79a392930bd0dd6b86cbfbfe1c40944110456e1dc6d809e5c46ece904/wrapt-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54ca1d5573f69b5fe1d74f1f65799c68015e82f685efec9fd8cfa40a094c44d0", size = 170022, upload-time = "2026-07-28T06:04:53.599Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/4f1b6918f5290db959d6e0c07f77385d87cede29c39c9cf8f145e9c82954/wrapt-2.3.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:242b60c21e30866e6a2fa606c612b47c553fa60c0eaeeeb7797fb842ac0ce609", size = 161043, upload-time = "2026-07-28T06:04:54.936Z" }, + { url = "https://files.pythonhosted.org/packages/01/e1/45d3cf74414780bdff6d0380467e003f6eb0f028b6c9403db868dbc7209c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3f3d7ec0a51fbfe00d3aef047641ff2c58b25565b4717fc1f90e050be01cba8", size = 168576, upload-time = "2026-07-28T06:04:56.261Z" }, + { url = "https://files.pythonhosted.org/packages/f3/73/2fa58dd97f191c997755e2c6d569a68f0c433db4e4b36099bdd7227b6cac/wrapt-2.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:261f53870cd4fb2bf38f9f972c56c728fd224cb7c65721307de59d9e7e6741ae", size = 159140, upload-time = "2026-07-28T06:04:57.754Z" }, + { url = "https://files.pythonhosted.org/packages/29/a8/08a56e2000a8816d449dcbad8c8b081697acbbd490821ceca0f9d8e8d20c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8159ec0b0cb7608175eb150de94c19e34f4d47ac655f5ca9baf45df6b688ffd3", size = 169263, upload-time = "2026-07-28T06:04:59.161Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d4/354e1725e35a73b2af4fa70a3e024c7a5d1bf1802dfb862dcb668aae0253/wrapt-2.3.0-cp313-cp313-win32.whl", hash = "sha256:10461884b3014fbfc8eb7d09a93c5f246363e6711d9d881f95eb8c27fdef049f", size = 78241, upload-time = "2026-07-28T06:05:00.507Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7e/34c87fa2174848dfee820322aaa318bab08913998ccecc8d2f57b4ad4639/wrapt-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac870cc97b73bb00ac353329e9559a4bebc47c4c86792ed9b23b58c15b6ad838", size = 81113, upload-time = "2026-07-28T06:05:01.839Z" }, + { url = "https://files.pythonhosted.org/packages/11/86/fcc9a530579e008c9478bb565a6cdfbfd33536660f069c8b91a6607c5050/wrapt-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:a65e8db2b4e90c2e7ade931086351c98ef420bf7a94ee08c95ac8a3cbbc43579", size = 80182, upload-time = "2026-07-28T06:05:03.152Z" }, + { url = "https://files.pythonhosted.org/packages/96/50/3864848b95b28ef73e17551fc8dccbff2628a834f52cf26a57f9c419fb83/wrapt-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fd1f2f557dd3491fe75905e578f4db967393d40d1a8f468edc4d40ac7f2d5944", size = 83921, upload-time = "2026-07-28T06:05:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4c/3d1921a60c3e8c71c540ff136e6a47a1fbccf7f671e818394889f7871d9c/wrapt-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9f5d2aec29dfc76c37e23897dee92766a3fd4f3bff3ae7fc9c6b4bf37d8c1360", size = 84412, upload-time = "2026-07-28T06:05:05.921Z" }, + { url = "https://files.pythonhosted.org/packages/fa/1a/4a796ff7adb26ada6d4b758c94d47a38320b085e7099afc088efbbcdb006/wrapt-2.3.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:646d20d413ffcd1b0a2f700076e2d0252d872dcb7754860a73e45a59ea883614", size = 207168, upload-time = "2026-07-28T06:05:07.256Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3e/d7777776806c579b761bac2f91721dda9f04c7a1b380213c5935cc750ae6/wrapt-2.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:379f670f45b7bb8993edd9f6fc36c6cc65edb81cffa0b504be34acb0303fff0a", size = 214351, upload-time = "2026-07-28T06:05:08.945Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/2d64d394df7bf181955b3bb562bf33c4492fb4be113f53071106d43ad8b5/wrapt-2.3.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6208f302f110295d64b22a7ac96500c791bf492dce4366e622e4912b077c9687", size = 199020, upload-time = "2026-07-28T06:05:10.418Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3d/fb31d3db7d9834d265fb1a27a2adf0ddf51557c67458c97b22439ad6ae3d/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ed635a9ca4f3a5a2b900c10c69e823373bc00ebc114b459383596d3487da3570", size = 209969, upload-time = "2026-07-28T06:05:11.983Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d1/8724b5da582e62070dc9bf4d8bf1972f317297eefd7ba1f2b5c6393ccf6c/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e3b9eaa742ae7a0aaaaad4ca4b69469d757af2d6e6663ef1dadc47adec0aeb41", size = 196324, upload-time = "2026-07-28T06:05:13.557Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/3d9ef411149543016ee6bcf3af707f787cebd946527452b94bf122e9b7b4/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0f7284f88f4833705132d06d3b425a43095c2cbd07c58166aac3ab646ba12a4", size = 202610, upload-time = "2026-07-28T06:05:15.048Z" }, + { url = "https://files.pythonhosted.org/packages/13/9b/4fc042ceb757866dd4a5fc057b3b736f2b360d3703ce9f830d83dc9226e0/wrapt-2.3.0-cp313-cp313t-win32.whl", hash = "sha256:7ebb274aba688b043429eb1500ff8a76ce0cb8ac0812ca3e301f06247b8722b3", size = 79178, upload-time = "2026-07-28T06:05:16.469Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/b94878f8eed809ca042685276bcea9f24e8c2ca7c9653bb80bbb920a68a5/wrapt-2.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c4bded758ad6f03b965830944a2f0bc5b2eb3767fe5a7310134315d1a6610e98", size = 82634, upload-time = "2026-07-28T06:05:18.026Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/663e1de5332a71685a729754312d327d4cada767c36e1c5a2db4c8de49e6/wrapt-2.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:d2cc64539da63e39ffb9c7ede849b6e8ddaaf7b3876b5cfb04efd85a5f3f4eb6", size = 81387, upload-time = "2026-07-28T06:05:19.417Z" }, + { url = "https://files.pythonhosted.org/packages/58/10/b073beaea89bc0d3670a75ff51139430a54b6af7ba7796507730634536dd/wrapt-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea52a0d0f08c584943d5764be0e84efa912c8da23c23e1e285ff2f5641c18fcc", size = 81978, upload-time = "2026-07-28T06:05:21.133Z" }, + { url = "https://files.pythonhosted.org/packages/b3/31/0916d9cebf848ed3f1a0c1888faee421747df77331e4db2bc527a9a85988/wrapt-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd85b0aa88efdb189d6ae2f35f4526943a8f091c38599c9c31478241c819e6a1", size = 82518, upload-time = "2026-07-28T06:05:22.562Z" }, + { url = "https://files.pythonhosted.org/packages/f5/73/31c1bf0f3384062751c2094dadb314916d70aa9b6bfd26d994b4a7b393fa/wrapt-2.3.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:141ed6211286a9660d8d6702de598b43f0934b4f0eda16393f100a80f501d945", size = 170187, upload-time = "2026-07-28T06:05:23.904Z" }, + { url = "https://files.pythonhosted.org/packages/ed/25/fce087d54b79b8905f3c3c9dd5f454bbd8d8acb80b960c4a6aee5b4659b3/wrapt-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e49885a62ec4ee854d1b9e6371fda6afd219917225752abf729a3f36d4df9a5", size = 169288, upload-time = "2026-07-28T06:05:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/0d09e6dddc6b7a7230ac77f50254b5980ab4fcd22976f72f8cc8a0404458/wrapt-2.3.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d6159c9b2fefec02314e1332dbbbfaf960e369dfd26bcf7f8b258b5732065b3", size = 160932, upload-time = "2026-07-28T06:05:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ca/0913af0d2ec0c43865d32d615f518fea66c13c5c930e489e9b0de248e9a8/wrapt-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24da48596326ef8e448cfa837b454f638713d3531262375f00e5a9681682fc07", size = 169017, upload-time = "2026-07-28T06:05:28.501Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f2/3d1e47ea81b822210f5df1bf942fd90780a75c055243d569b664529dea88/wrapt-2.3.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cd3a2edf0427013736b8127955cec62608c56e53ea47e82812ea32059cda407f", size = 159065, upload-time = "2026-07-28T06:05:30.01Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/ef2066ced8e5fca204e2b361e9708e36555b40949c583d997ea3b590817d/wrapt-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fa0df3bff4e7ce45759f33fd39335fe2f60477bb9ecf7b8aa41e7d07ee36a23", size = 168821, upload-time = "2026-07-28T06:05:31.649Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/016104650d4e572fa91506eb396b3dd8efbccc9284fdc1c9479c3d21db28/wrapt-2.3.0-cp314-cp314-win32.whl", hash = "sha256:2935d5454b3f179a29b12cf390ee47246740ba2c3a7545b1b46ba31a5f2a4a0b", size = 78700, upload-time = "2026-07-28T06:05:33.391Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/6fdc20a9f2ca304748b3f0819cbf377d55260562777bf0b615431bc3c181/wrapt-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:cc2cea812e5cb179a796b766747e7d3b21088760d8deb95676d482b8c8e6fa7d", size = 81422, upload-time = "2026-07-28T06:05:34.774Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a4/9cbd53bf05746bea2c392af39cb052427a8ec95cbd494d930733d8f44681/wrapt-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:22cc5c0a717bd4da87018ae0bffd4c19c6fb679d3ff357216ba566ab26c76cab", size = 80639, upload-time = "2026-07-28T06:05:36.228Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/6c5e4a0f66ea0d2b2dd267e8dd05a0014eea56840b3c8595d40b0a5d1f91/wrapt-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a6b5984cd65dd639546f0eb4b8eacf1c31cb2fe9fb5c27bffe240987cdb2cf84", size = 84030, upload-time = "2026-07-28T06:05:37.714Z" }, + { url = "https://files.pythonhosted.org/packages/6a/eb/a1aedf03283bc9cbf8a1783995ddc54e3c5a86878f19002d2c428494f4c5/wrapt-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c88abcf53daef80e01a75c7530e727fa6e2c1888fe83e3dcdba4c96216a1f5c7", size = 84419, upload-time = "2026-07-28T06:05:39.131Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/50d511c0dc5105563849e86daa3e16ac7feef699f79fb05af45ea70107d5/wrapt-2.3.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85de890ff968196e92dd1ae73a9fb8970495e7650a457b1c9ef0ac3dd550bce2", size = 207171, upload-time = "2026-07-28T06:05:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/3f/59/9b538cf7795217e810699d16bc88b96a830d9b5c403eb2ec2db6b5f2ae81/wrapt-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50f416b74d092bb9f41b424e90dd457f365f7ba4b11de62a23679769a21bd85c", size = 214329, upload-time = "2026-07-28T06:05:42.287Z" }, + { url = "https://files.pythonhosted.org/packages/b3/28/9935d62b1499e5c8b3d191e99ba4eb31ca237a0b699142011a837e9dc7ea/wrapt-2.3.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39febbee6d77301d31da6996b152ce52452da7c7ef72aba10c2fa976dff9c295", size = 199079, upload-time = "2026-07-28T06:05:43.958Z" }, + { url = "https://files.pythonhosted.org/packages/2b/01/4446b80fa2ffa47a3449b250d004ba1c1937f07f64a179608fec735df866/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93513bec052c6cd987f9f580c3df068c8bc4ebae6543736be3ca7ec5959cafcd", size = 209992, upload-time = "2026-07-28T06:05:45.677Z" }, + { url = "https://files.pythonhosted.org/packages/d4/07/56f26c9f9979586a021e8148747004aba4498f49458c90b0502969b904e1/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:729126e667da34d251b8ebf8a45ef0c5ddadc21542b3d6e1abf4259ece6508df", size = 196334, upload-time = "2026-07-28T06:05:47.608Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/6d7bcc895b0f28b2250e10908f060687b9165429dcd7f22ddb3d4c031b74/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:626b69db2021aa01671ec7bbc9740e558522bd44c18cf2ce69bf3d666a014109", size = 202644, upload-time = "2026-07-28T06:05:49.183Z" }, + { url = "https://files.pythonhosted.org/packages/cd/25/7860927edba06b758b8852a6f02e832be715563c67a6795d94350bc81099/wrapt-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:629d73378082c00a8173031f9fb30a3ac6abbc894a5bfdfae71fabc60642d501", size = 79685, upload-time = "2026-07-28T06:05:50.976Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0f/270bafe92fde3b069a39bc01e39ee79340895b335640df861d43d2a51885/wrapt-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:42869085687f0aefd57c0f636c3f9354f8ffb321a8ba9cb52d19beb796e561c5", size = 83104, upload-time = "2026-07-28T06:05:52.405Z" }, + { url = "https://files.pythonhosted.org/packages/55/b3/af176d79a8515a8a720eccdad9a96f6e31a30abf2865430c8c42adf2fd13/wrapt-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b1e5aa486e269b00ed35e64771c7d0ab8096cfd2643405ca8cd60ebedc099a51", size = 81774, upload-time = "2026-07-28T06:05:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/00/39/3daf9f47be208606586de4568ba6713db53ebc8fd7a575aea1fe57983b69/wrapt-2.3.0-py3-none-any.whl", hash = "sha256:d8c7ed08477429752b8c44991f40ad7838b18332a160698740a6bfbc10d998a2", size = 61866, upload-time = "2026-07-28T06:06:12.9Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] From cf8156cbc0a830675aca0f5c70c70794c0b8a760 Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Fri, 14 Aug 2026 14:33:29 -0500 Subject: [PATCH 2/3] fix(release): harden publication boundaries Isolate mutable Hermes contract execution from the one artifact build, and finalize release receipts only from verified PyPI bytes. Dedicated App credentials and immutable-release checks keep source promotion narrow. Idempotent remote-ref validation lets an ambiguous successful push resume instead of stranding the release. --- .github/workflows/release.yml | 362 +++++++++++++++++++++++++++------ README.md | 56 +++-- scripts/release_contract.py | 154 ++++++++++++-- tests/test_release_contract.py | 241 +++++++++++++++++++--- 4 files changed, 701 insertions(+), 112 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b441b0..256ee3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: outputs: enabled: ${{ steps.gate.outputs.enabled }} steps: - - name: Enforce reviewed activation gate + - name: Enforce the reviewed release switch id: gate env: RELEASE_ENABLED: ${{ vars.SEMANTIC_RELEASE_ENABLED }} @@ -32,7 +32,7 @@ jobs: fi echo "enabled=true" >> "$GITHUB_OUTPUT" - prepare: + materialize: needs: activation if: needs.activation.outputs.enabled == 'true' runs-on: ubuntu-latest @@ -43,6 +43,7 @@ jobs: previous_version: ${{ steps.intent.outputs.previous_version }} released: ${{ steps.intent.outputs.released }} release_sha: ${{ steps.identity.outputs.release_sha }} + source_artifact_name: ${{ steps.identity.outputs.source_artifact_name }} tag: ${{ steps.identity.outputs.tag }} version: ${{ steps.identity.outputs.version }} steps: @@ -50,6 +51,7 @@ jobs: uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 + persist-credentials: false ref: ${{ github.sha }} - name: Install uv uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 @@ -60,8 +62,6 @@ jobs: run: uv sync --frozen - name: Validate the tagged baseline and strict release intent id: intent - env: - GH_TOKEN: ${{ github.token }} run: | baseline="$(uv run python scripts/release_contract.py validate-baseline --repository .)" baseline_tag="$(python -c 'import json,sys; print(json.load(sys.stdin)["tag"])' <<< "$baseline")" @@ -86,8 +86,6 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Materialize the release commit and tag locally if: steps.intent.outputs.released == 'true' - env: - GH_TOKEN: ${{ github.token }} run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" @@ -103,7 +101,7 @@ jobs: tag="v$EXPECTED_VERSION" test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT" test "$(git rev-list -n 1 "$tag")" = "$release_sha" - test -z "$(git status --porcelain)" + test -z "$(git status --porcelain --untracked-files=no)" unexpected="$(git diff --name-only "$EXPECTED_PARENT" "$release_sha" | grep -Ev '^(CHANGELOG\.md|pyproject\.toml|uv\.lock)$' || true)" test -z "$unexpected" { @@ -111,6 +109,7 @@ jobs: echo "tag=$tag" echo "version=$EXPECTED_VERSION" echo "artifact_name=release-$EXPECTED_VERSION-$release_sha" + echo "source_artifact_name=release-source-$EXPECTED_VERSION-$release_sha" } >> "$GITHUB_OUTPUT" - name: Reject an already-published PyPI version if: steps.intent.outputs.released == 'true' @@ -118,6 +117,7 @@ jobs: VERSION: ${{ steps.identity.outputs.version }} run: | status="$(curl --silent --show-error --output /dev/null \ + --connect-timeout 10 --max-time 30 \ --write-out '%{http_code}' \ "https://pypi.org/pypi/hermes-plugin-kit/$VERSION/json")" case "$status" in @@ -131,38 +131,240 @@ jobs: exit 1 ;; esac - - name: Prove the exact release SHA with unit and public contracts + - name: Materialize the immutable release source bundle before tests if: steps.intent.outputs.released == 'true' env: RELEASE_SHA: ${{ steps.identity.outputs.release_sha }} + TAG: ${{ steps.identity.outputs.tag }} run: | test "$(git rev-parse HEAD)" = "$RELEASE_SHA" - make test - - name: Prove the exact release SHA against Hermes + test "$(git rev-list -n 1 "$TAG")" = "$RELEASE_SHA" + test -z "$(git status --porcelain --untracked-files=no)" + git branch release-candidate "$RELEASE_SHA" + git bundle create release-source.bundle \ + refs/heads/release-candidate "refs/tags/$TAG" + git branch --delete --force release-candidate + sha256sum release-source.bundle > release-source.sha256 + - name: Upload the immutable release source if: steps.intent.outputs.released == 'true' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: ${{ steps.identity.outputs.source_artifact_name }} + path: | + release-source.bundle + release-source.sha256 + if-no-files-found: error + compression-level: 0 + retention-days: 90 + + immutable-release-control: + needs: materialize + if: needs.materialize.outputs.released == 'true' + runs-on: ubuntu-latest + environment: source-promotion + permissions: + contents: read + outputs: + enabled: ${{ steps.verify.outputs.enabled }} + steps: + - name: Mint a current-repository administration-read token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.SOURCE_PROMOTION_APP_CLIENT_ID }} + private-key: ${{ secrets.SOURCE_PROMOTION_APP_PRIVATE_KEY }} + permission-administration: read + - name: Fail closed unless immutable releases are enabled + id: verify env: - RELEASE_SHA: ${{ steps.identity.outputs.release_sha }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + enabled="$(gh api "repos/$GITHUB_REPOSITORY/immutable-releases" --jq '.enabled')" + test "$enabled" = "true" + echo "enabled=true" >> "$GITHUB_OUTPUT" + + unit-public-tests: + needs: materialize + if: needs.materialize.outputs.released == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out a fresh trigger workspace + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + python-version: "3.11" + enable-cache: true + - name: Download the immutable release source + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: ${{ needs.materialize.outputs.source_artifact_name }} + - name: Restore and verify the exact release source + env: + RELEASE_SHA: ${{ needs.materialize.outputs.release_sha }} + TAG: ${{ needs.materialize.outputs.tag }} + TRIGGER_SHA: ${{ github.sha }} + run: | + sha256sum --check release-source.sha256 + git bundle verify release-source.bundle + git fetch release-source.bundle \ + refs/heads/release-candidate:refs/heads/release-candidate \ + "refs/tags/$TAG:refs/tags/$TAG" + git checkout --detach "$RELEASE_SHA" + test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + test "$(git rev-parse HEAD^)" = "$TRIGGER_SHA" + test "$(git rev-list -n 1 "$TAG")" = "$RELEASE_SHA" + test -z "$(git status --porcelain --untracked-files=no)" + - name: Test the exact SHA with unit and public contracts + env: + RELEASE_SHA: ${{ needs.materialize.outputs.release_sha }} + TAG: ${{ needs.materialize.outputs.tag }} + TRIGGER_SHA: ${{ github.sha }} + run: | + test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + test -z "$(git status --porcelain --untracked-files=no)" + make test + test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + test "$(git rev-parse HEAD^)" = "$TRIGGER_SHA" + test "$(git rev-list -n 1 "$TAG")" = "$RELEASE_SHA" + test -z "$(git status --porcelain --untracked-files=no)" + + hermes-contract: + needs: materialize + if: needs.materialize.outputs.released == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out a fresh isolated trigger workspace + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + python-version: "3.11" + enable-cache: true + - name: Download the immutable release source + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: ${{ needs.materialize.outputs.source_artifact_name }} + - name: Restore and verify the exact release source + env: + RELEASE_SHA: ${{ needs.materialize.outputs.release_sha }} + TAG: ${{ needs.materialize.outputs.tag }} + TRIGGER_SHA: ${{ github.sha }} + run: | + sha256sum --check release-source.sha256 + git bundle verify release-source.bundle + git fetch release-source.bundle \ + refs/heads/release-candidate:refs/heads/release-candidate \ + "refs/tags/$TAG:refs/tags/$TAG" + git checkout --detach "$RELEASE_SHA" + test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + test "$(git rev-parse HEAD^)" = "$TRIGGER_SHA" + test "$(git rev-list -n 1 "$TAG")" = "$RELEASE_SHA" + test -z "$(git status --porcelain --untracked-files=no)" + - name: Prove the exact SHA against mutable upstream Hermes + env: + RELEASE_SHA: ${{ needs.materialize.outputs.release_sha }} + TAG: ${{ needs.materialize.outputs.tag }} + TRIGGER_SHA: ${{ github.sha }} run: | test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + test -z "$(git status --porcelain --untracked-files=no)" make test-contract git -C .hermes-agent rev-parse HEAD > hermes-source-sha.txt + test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + test "$(git rev-parse HEAD^)" = "$TRIGGER_SHA" + test "$(git rev-list -n 1 "$TAG")" = "$RELEASE_SHA" + test -z "$(git status --porcelain --untracked-files=no)" + - name: Upload isolated Hermes evidence only + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: hermes-evidence-${{ needs.materialize.outputs.release_sha }} + path: hermes-source-sha.txt + if-no-files-found: error + retention-days: 90 + + build: + needs: [materialize, unit-public-tests, hermes-contract] + if: needs.materialize.outputs.released == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + artifact_name: ${{ needs.materialize.outputs.artifact_name }} + release_sha: ${{ needs.materialize.outputs.release_sha }} + tag: ${{ needs.materialize.outputs.tag }} + version: ${{ needs.materialize.outputs.version }} + steps: + - name: Check out a fresh build workspace + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + python-version: "3.11" + enable-cache: true + - name: Download the immutable release source + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: ${{ needs.materialize.outputs.source_artifact_name }} + - name: Download isolated Hermes evidence + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: hermes-evidence-${{ needs.materialize.outputs.release_sha }} + - name: Restore and verify the exact release source + env: + RELEASE_SHA: ${{ needs.materialize.outputs.release_sha }} + TAG: ${{ needs.materialize.outputs.tag }} + TRIGGER_SHA: ${{ github.sha }} + run: | + sha256sum --check release-source.sha256 + git bundle verify release-source.bundle + git fetch release-source.bundle \ + refs/heads/release-candidate:refs/heads/release-candidate \ + "refs/tags/$TAG:refs/tags/$TAG" + git checkout --detach "$RELEASE_SHA" + test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + test "$(git rev-parse HEAD^)" = "$TRIGGER_SHA" + test "$(git rev-list -n 1 "$TAG")" = "$RELEASE_SHA" + test -z "$(git status --porcelain --untracked-files=no)" + grep -Eq '^[0-9a-f]{40}$' hermes-source-sha.txt - name: Build the exact release SHA once and validate metadata - if: steps.intent.outputs.released == 'true' env: - RELEASE_SHA: ${{ steps.identity.outputs.release_sha }} + RELEASE_SHA: ${{ needs.materialize.outputs.release_sha }} + TAG: ${{ needs.materialize.outputs.tag }} + TRIGGER_SHA: ${{ github.sha }} run: | test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + test -z "$(git status --porcelain --untracked-files=no)" make build make check-dist - - name: Create and verify the immutable release receipt - if: steps.intent.outputs.released == 'true' + test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + test "$(git rev-parse HEAD^)" = "$TRIGGER_SHA" + test "$(git rev-list -n 1 "$TAG")" = "$RELEASE_SHA" + test -z "$(git status --porcelain --untracked-files=no)" + - name: Create and verify the prepublication manifest env: - PREVIOUS_VERSION: ${{ steps.intent.outputs.previous_version }} - RELEASE_SHA: ${{ steps.identity.outputs.release_sha }} - TAG: ${{ steps.identity.outputs.tag }} - VERSION: ${{ steps.identity.outputs.version }} + PREVIOUS_VERSION: ${{ needs.materialize.outputs.previous_version }} + RELEASE_SHA: ${{ needs.materialize.outputs.release_sha }} + TAG: ${{ needs.materialize.outputs.tag }} + VERSION: ${{ needs.materialize.outputs.version }} run: | - uv run python scripts/release_contract.py create-receipt \ + uv run python scripts/release_contract.py create-manifest \ --previous-version "$PREVIOUS_VERSION" \ --version "$VERSION" \ --tag "$TAG" \ @@ -172,61 +374,62 @@ jobs: --run-id "${{ github.run_id }}" \ --run-attempt "${{ github.run_attempt }}" \ --artifact-dir dist \ - --output release-receipt.json - uv run python scripts/release_contract.py verify-receipt \ - --receipt release-receipt.json \ + --output release-manifest.json + uv run python scripts/release_contract.py verify-manifest \ + --receipt release-manifest.json \ --artifact-dir dist - - name: Bundle the tested source and immutable artifacts - if: steps.intent.outputs.released == 'true' - env: - RELEASE_SHA: ${{ steps.identity.outputs.release_sha }} - TAG: ${{ steps.identity.outputs.tag }} + - name: Bundle the tested immutable artifacts without rebuilding run: | - git branch release-candidate "$RELEASE_SHA" - git bundle create release-source.bundle \ - refs/heads/release-candidate "refs/tags/$TAG" - git branch --delete --force release-candidate - sha256sum release-source.bundle release-receipt.json dist/* \ + sha256sum release-source.bundle release-manifest.json dist/* \ > release-payload.sha256 - name: Upload the tested release payload - if: steps.intent.outputs.released == 'true' uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: - name: ${{ steps.identity.outputs.artifact_name }} + name: ${{ needs.materialize.outputs.artifact_name }} path: | dist/ + release-manifest.json release-payload.sha256 - release-receipt.json release-source.bundle if-no-files-found: error compression-level: 0 retention-days: 90 promote-source: - needs: prepare - if: needs.prepare.outputs.released == 'true' + needs: [materialize, build, immutable-release-control] + if: needs.materialize.outputs.released == 'true' runs-on: ubuntu-latest + environment: source-promotion permissions: - contents: write + contents: read outputs: - artifact_name: ${{ needs.prepare.outputs.artifact_name }} - release_sha: ${{ needs.prepare.outputs.release_sha }} - tag: ${{ needs.prepare.outputs.tag }} - version: ${{ needs.prepare.outputs.version }} + artifact_name: ${{ needs.materialize.outputs.artifact_name }} + release_sha: ${{ needs.materialize.outputs.release_sha }} + tag: ${{ needs.materialize.outputs.tag }} + version: ${{ needs.materialize.outputs.version }} steps: - - name: Check out the guarded parent revision + - name: Check out the guarded parent revision without credentials uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 + persist-credentials: false ref: ${{ github.sha }} + - name: Mint the dedicated source-promotion token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.SOURCE_PROMOTION_APP_CLIENT_ID }} + private-key: ${{ secrets.SOURCE_PROMOTION_APP_PRIVATE_KEY }} + permission-contents: write - name: Download the tested release payload uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: - name: ${{ needs.prepare.outputs.artifact_name }} + name: ${{ needs.materialize.outputs.artifact_name }} - name: Validate and atomically push only the tested source env: - RELEASE_SHA: ${{ needs.prepare.outputs.release_sha }} - TAG: ${{ needs.prepare.outputs.tag }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + RELEASE_SHA: ${{ needs.materialize.outputs.release_sha }} + TAG: ${{ needs.materialize.outputs.tag }} TRIGGER_SHA: ${{ github.sha }} run: | sha256sum --check release-payload.sha256 @@ -239,15 +442,32 @@ jobs: test "$(git rev-list -n 1 "$TAG")" = "$RELEASE_SHA" unexpected="$(git diff --name-only "$TRIGGER_SHA" "$RELEASE_SHA" | grep -Ev '^(CHANGELOG\.md|pyproject\.toml|uv\.lock)$' || true)" test -z "$unexpected" + remote_tag_sha() { + sha="$(git ls-remote origin "refs/tags/$TAG^{}" | cut -f1)" + if [ -z "$sha" ]; then + sha="$(git ls-remote origin "refs/tags/$TAG" | cut -f1)" + fi + printf '%s' "$sha" + } remote_main="$(git ls-remote origin refs/heads/main | cut -f1)" - test "$remote_main" = "$TRIGGER_SHA" - if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then - echo "Refusing existing release tag $TAG" >&2 + remote_tag="$(remote_tag_sha)" + if [ "$remote_main" = "$RELEASE_SHA" ] && [ "$remote_tag" = "$RELEASE_SHA" ]; then + echo "Source and tag already match the tested release; resuming publication." + elif [ "$remote_main" = "$TRIGGER_SHA" ] && [ -z "$remote_tag" ]; then + gh auth setup-git + if ! git push --atomic origin \ + "$RELEASE_SHA:refs/heads/main" \ + "refs/tags/$TAG:refs/tags/$TAG"; then + echo "Source push returned an error; verifying remote refs before failing." >&2 + fi + else + echo "Remote refs do not match the releasable parent or exact tested release." >&2 exit 1 fi - git push --atomic origin \ - "$RELEASE_SHA:refs/heads/main" \ - "refs/tags/$TAG:refs/tags/$TAG" + remote_main="$(git ls-remote origin refs/heads/main | cut -f1)" + remote_tag="$(remote_tag_sha)" + test "$remote_main" = "$RELEASE_SHA" + test "$remote_tag" = "$RELEASE_SHA" publish: needs: promote-source @@ -276,23 +496,42 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + outputs: + receipt_artifact_name: ${{ steps.receipt.outputs.artifact_name }} steps: - name: Check out the published source identity uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: + fetch-depth: 0 + persist-credentials: false ref: ${{ needs.promote-source.outputs.release_sha }} - name: Download the published release payload uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: ${{ needs.promote-source.outputs.artifact_name }} - - name: Verify PyPI filenames, metadata hashes, and downloaded bytes + - name: Verify PyPI bytes and finalize the discoverable receipt + id: receipt env: RELEASE_SHA: ${{ needs.promote-source.outputs.release_sha }} + TAG: ${{ needs.promote-source.outputs.tag }} run: | test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + test "$(git rev-list -n 1 "$TAG")" = "$RELEASE_SHA" + test -z "$(git status --porcelain --untracked-files=no)" sha256sum --check release-payload.sha256 - python scripts/release_contract.py verify-pypi \ + python scripts/release_contract.py finalize-receipt \ + --manifest release-manifest.json \ + --output release-receipt.json + python scripts/release_contract.py verify-final-receipt \ --receipt release-receipt.json + echo "artifact_name=release-receipt-$RELEASE_SHA" >> "$GITHUB_OUTPUT" + - name: Upload the finalized discoverable receipt + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: ${{ steps.receipt.outputs.artifact_name }} + path: release-receipt.json + if-no-files-found: error + retention-days: 90 github-release: needs: [promote-source, publish, verify-pypi] @@ -304,9 +543,14 @@ jobs: uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: ${{ needs.promote-source.outputs.artifact_name }} + - name: Download the finalized discoverable receipt + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: ${{ needs.verify-pypi.outputs.receipt_artifact_name }} + path: final-receipt - name: Verify the payload published by the preceding job run: sha256sum --check release-payload.sha256 - - name: Create the discoverable GitHub Release and receipt + - name: Create the immutable discoverable GitHub Release and receipt env: GH_TOKEN: ${{ github.token }} RELEASE_SHA: ${{ needs.promote-source.outputs.release_sha }} @@ -320,17 +564,19 @@ jobs: fi test "$remote_tag" = "$RELEASE_SHA" if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + test "$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isImmutable --jq '.isImmutable')" = "true" mkdir existing-release gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ --dir existing-release - cmp release-receipt.json existing-release/release-receipt.json + cmp final-receipt/release-receipt.json existing-release/release-receipt.json for artifact in dist/*; do cmp "$artifact" "existing-release/$(basename "$artifact")" done else - gh release create "$TAG" dist/* release-receipt.json \ + gh release create "$TAG" dist/* final-receipt/release-receipt.json \ --repo "$GITHUB_REPOSITORY" \ --verify-tag \ --title "hermes-plugin-kit $VERSION" \ --generate-notes + test "$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isImmutable --jq '.isImmutable')" = "true" fi diff --git a/README.md b/README.md index f8b4e54..aff5363 100644 --- a/README.md +++ b/README.md @@ -741,14 +741,23 @@ commits do not release by themselves; an invalid commit in release history fails closed. Major releases are published but their receipt is always `manual_migration_required`, never an automatic promotion candidate. -The workflow first creates the version/CHANGELOG commit and tag locally. It -tests that exact final SHA with the unit and real-Hermes contract suites, builds -the wheel and sdist once, validates their metadata, and records their filenames -and SHA-256 values. Only then may a narrow write job atomically push that tested -commit and tag. A separate `pypi` environment publishes the uploaded artifacts -through OIDC Trusted Publishing; no password or API token is used. The GitHub -Release and its JSON receipt are created only after PyPI succeeds. A failed -publish is retried from the retained workflow artifact and must not rebuild it. +The workflow first creates the version/CHANGELOG commit and tag locally, then +bundles that exact final source before executing tests. Fresh, separate jobs +restore the bundle for unit/public tests, the mutable upstream-Hermes contract, +and the one artifact build. The build job is gated on both test jobs and never +checks out or executes Hermes code. Every lane verifies the release SHA, tag, +parent, and clean tracked source before continuing. + +The build produces the wheel and sdist once, validates their metadata, and +writes a prepublication manifest containing their filenames, sizes, and SHA-256 +values. Only then may the protected source-promotion job atomically push that +tested commit and tag. A separate `pypi` environment publishes the uploaded +artifacts through OIDC Trusted Publishing; no password or API token is used. +After publication, the workflow downloads and hashes the registry files and +turns the manifest into the final receipt by adding each verified direct +`https://files.pythonhosted.org/` URL. The immutable GitHub Release uploads that +final receipt only after PyPI verification. A failed publish is retried from +the retained workflow artifact and must not rebuild it. Release automation is deliberately disarmed unless the repository variable `SEMANTIC_RELEASE_ENABLED` is exactly `true`. Set it only after all activation @@ -758,15 +767,32 @@ prerequisites have been reviewed: `offendingcommit/hermes-plugin-kit`, workflow `release.yml`, and environment `pypi`; - the protected GitHub environment is named exactly `pypi`; -- the `main` ruleset requires the ordinary test workflow and permits only the - guarded Semantic Release source-promotion job to advance the release commit; +- GitHub immutable releases are enabled for the repository; the workflow + verifies the repository control with Administration-read permission after a + release intent is materialized but before source promotion, and verifies + `isImmutable` after publication. Non-releasing commits never enter a + protected environment; +- a protected environment named exactly `source-promotion` contains variable + `SOURCE_PROMOTION_APP_CLIENT_ID` and secret + `SOURCE_PROMOTION_APP_PRIVATE_KEY` for a dedicated GitHub App installed only + on this repository. The App has repository Administration read and Contents + write permissions; no PAT is used; +- the `main` ruleset requires the ordinary test workflow and names that + dedicated GitHub App as its sole source-promotion bypass actor. Generic + Actions credentials and the default `GITHUB_TOKEN` must not bypass it; - the existing `0.7.0` source baseline has a reviewed immutable `v0.7.0` tag. -The repository currently creates none of those external controls itself. A -missing switch, PyPI project, baseline tag, test, build, metadata check, source -identity, or artifact hash stops before publication. The release receipt is the -discoverable boundary for downstream qualification; polling and recovery from -a missed notification belong to that downstream system. +This change does not create or mutate any of those external controls. At review +time immutable releases were disabled, the required environments/ruleset/App +were not configured, and the PyPI project did not yet exist; a pending Trusted +Publisher supports that first OIDC publication. Keep +`SEMANTIC_RELEASE_ENABLED` unset until the full checklist is configured and a +generic-token rejection plus dedicated-App promotion have been exercised in an +isolated validation. A missing switch, control, baseline tag, test, build, +metadata check, source identity, or artifact hash stops before publication. The +final release receipt is the discoverable boundary for downstream +qualification; polling and recovery from a missed notification belong to that +downstream system. ## License diff --git a/scripts/release_contract.py b/scripts/release_contract.py index ed1cf2e..bcd1944 100644 --- a/scripts/release_contract.py +++ b/scripts/release_contract.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Build and verify the immutable hermes-plugin-kit release receipt.""" +"""Build and verify hermes-plugin-kit release manifests and receipts.""" from __future__ import annotations @@ -249,7 +249,7 @@ def _validate_sha(value: str, label: str) -> None: raise ReleaseContractError(f"{label} must be a lowercase 40-character Git SHA") -def create_release_receipt( +def create_release_manifest( *, previous_version: str, version: str, @@ -262,7 +262,7 @@ def create_release_receipt( artifact_dir: Path, output_path: Path, ) -> dict[str, object]: - """Write the canonical receipt for the already-tested distribution files.""" + """Write the prepublication manifest for already-tested distributions.""" release_class = _release_class(previous_version, version) expected_tag = f"{TAG_PREFIX}{version}" @@ -281,6 +281,7 @@ def create_release_receipt( passed = {"result": "passed", "source_sha": source_sha, "status": "passed"} receipt: dict[str, object] = { "schema_version": 1, + "receipt_state": "prepublication", "package": PACKAGE_NAME, "previous_version": previous_version, "version": version, @@ -320,8 +321,8 @@ def create_release_receipt( return receipt -def verify_release_receipt(receipt_path: Path, artifact_dir: Path) -> dict[str, object]: - """Recompute identity and hashes instead of trusting receipt assertions.""" +def verify_release_manifest(receipt_path: Path, artifact_dir: Path) -> dict[str, object]: + """Recompute prepublication identity and hashes instead of trusting it.""" try: receipt = json.loads(receipt_path.read_text(encoding="utf-8")) @@ -329,6 +330,8 @@ def verify_release_receipt(receipt_path: Path, artifact_dir: Path) -> dict[str, raise ReleaseContractError(f"cannot read release receipt: {error}") from error if not isinstance(receipt, dict) or receipt.get("schema_version") != 1: raise ReleaseContractError("unsupported release receipt schema") + if receipt.get("receipt_state") != "prepublication": + raise ReleaseContractError("expected a prepublication release manifest") if receipt.get("package") != PACKAGE_NAME: raise ReleaseContractError("release receipt package mismatch") @@ -434,11 +437,12 @@ def verify_pypi_release( raise ReleaseContractError("release receipt identity is incomplete") SemVer.parse(version) expected = { - item["filename"]: item["sha256"] + item["filename"]: item for item in artifacts if isinstance(item, dict) and isinstance(item.get("filename"), str) and isinstance(item.get("sha256"), str) + and isinstance(item.get("size"), int) } if len(expected) != 2: raise ReleaseContractError("release receipt must name one wheel and one sdist") @@ -459,10 +463,15 @@ def verify_pypi_release( for item in urls if isinstance(item, dict) and isinstance(item.get("filename"), str) } - if set(published) != set(expected): + if ( + len(published) != len(urls) + or len(published) != len(expected) + or set(published) != set(expected) + ): raise ReleaseContractError("PyPI filenames do not match the release receipt") - for filename, expected_sha in expected.items(): + for filename, expected_artifact in expected.items(): + expected_sha = expected_artifact["sha256"] item = published[filename] digests = item.get("digests") url = item.get("url") @@ -481,9 +490,109 @@ def verify_pypi_release( raise ReleaseContractError(f"cannot download PyPI artifact {filename}: {error}") from error if hashlib.sha256(published_bytes).hexdigest() != expected_sha: raise ReleaseContractError(f"downloaded PyPI SHA-256 mismatch for {filename}") + if len(published_bytes) != expected_artifact["size"]: + raise ReleaseContractError(f"downloaded PyPI size mismatch for {filename}") return metadata +def finalize_release_receipt( + manifest_path: Path, + output_path: Path, + *, + fetch_json: Callable[[str], dict[str, object]] = _fetch_json, + fetch_bytes: Callable[[str], bytes] = _fetch_bytes, +) -> dict[str, object]: + """Verify PyPI and persist its direct immutable URLs in the final receipt.""" + + metadata = verify_pypi_release( + manifest_path, + fetch_json=fetch_json, + fetch_bytes=fetch_bytes, + ) + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ReleaseContractError(f"cannot read release manifest: {error}") from error + if not isinstance(manifest, dict) or manifest.get("receipt_state") != "prepublication": + raise ReleaseContractError("expected a prepublication release manifest") + + urls = metadata.get("urls") + assert isinstance(urls, list) + published = { + item["filename"]: item["url"] + for item in urls + if isinstance(item, dict) + and isinstance(item.get("filename"), str) + and isinstance(item.get("url"), str) + } + receipt = json.loads(json.dumps(manifest)) + receipt["receipt_state"] = "published" + for artifact in receipt["artifacts"]: + artifact["url"] = published[artifact["filename"]] + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return verify_final_release_receipt(output_path) + + +def verify_final_release_receipt(receipt_path: Path) -> dict[str, object]: + """Validate the offline shape of a finalized, discoverable receipt.""" + + try: + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ReleaseContractError(f"cannot read final release receipt: {error}") from error + if not isinstance(receipt, dict) or receipt.get("schema_version") != 1: + raise ReleaseContractError("unsupported final release receipt schema") + if receipt.get("receipt_state") != "published": + raise ReleaseContractError("final release receipt is not published") + if receipt.get("package") != PACKAGE_NAME: + raise ReleaseContractError("final release receipt package mismatch") + version = receipt.get("version") + source_sha = receipt.get("source_sha") + artifacts = receipt.get("artifacts") + if not isinstance(version, str) or not isinstance(source_sha, str): + raise ReleaseContractError("final release receipt identity is incomplete") + SemVer.parse(version) + _validate_sha(source_sha, "source_sha") + if receipt.get("tag") != f"{TAG_PREFIX}{version}": + raise ReleaseContractError("final release receipt tag mismatch") + if not isinstance(artifacts, list) or len(artifacts) != 2: + raise ReleaseContractError("final release receipt must name one wheel and one sdist") + + filenames: set[str] = set() + for artifact in artifacts: + if not isinstance(artifact, dict): + raise ReleaseContractError("final release artifact record must be an object") + filename = artifact.get("filename") + sha256 = artifact.get("sha256") + size = artifact.get("size") + url = artifact.get("url") + if ( + not isinstance(filename, str) + or not re.fullmatch(r"[A-Za-z0-9_.+-]+", filename) + or not isinstance(sha256, str) + or not re.fullmatch(r"[0-9a-f]{64}", sha256) + or not isinstance(size, int) + or size < 1 + or not isinstance(url, str) + ): + raise ReleaseContractError("final release artifact identity is incomplete") + parsed_url = urlparse(url) + if parsed_url.scheme != "https" or parsed_url.hostname != "files.pythonhosted.org": + raise ReleaseContractError(f"unexpected PyPI artifact URL for {filename}") + filenames.add(filename) + if len(filenames) != len(artifacts): + raise ReleaseContractError("final release receipt contains duplicate artifacts") + if sum(filename.endswith(".whl") for filename in filenames) != 1 or sum( + filename.endswith(".tar.gz") for filename in filenames + ) != 1: + raise ReleaseContractError("final release receipt must name one wheel and one sdist") + return receipt + + def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) subparsers = parser.add_subparsers(dest="command", required=True) @@ -495,7 +604,7 @@ def _parser() -> argparse.ArgumentParser: history.add_argument("--repository", type=Path, default=Path.cwd()) history.add_argument("--baseline-tag", required=True) - create = subparsers.add_parser("create-receipt") + create = subparsers.add_parser("create-manifest") create.add_argument("--previous-version", required=True) create.add_argument("--version", required=True) create.add_argument("--tag", required=True) @@ -507,7 +616,7 @@ def _parser() -> argparse.ArgumentParser: create.add_argument("--artifact-dir", required=True, type=Path) create.add_argument("--output", required=True, type=Path) - verify = subparsers.add_parser("verify-receipt") + verify = subparsers.add_parser("verify-manifest") verify.add_argument("--receipt", required=True, type=Path) verify.add_argument("--artifact-dir", required=True, type=Path) @@ -515,6 +624,15 @@ def _parser() -> argparse.ArgumentParser: pypi.add_argument("--receipt", required=True, type=Path) pypi.add_argument("--attempts", type=int, default=12) pypi.add_argument("--delay-seconds", type=int, default=10) + + finalize = subparsers.add_parser("finalize-receipt") + finalize.add_argument("--manifest", required=True, type=Path) + finalize.add_argument("--output", required=True, type=Path) + finalize.add_argument("--attempts", type=int, default=12) + finalize.add_argument("--delay-seconds", type=int, default=10) + + final = subparsers.add_parser("verify-final-receipt") + final.add_argument("--receipt", required=True, type=Path) return parser @@ -530,8 +648,8 @@ def main(argv: Sequence[str] | None = None) -> int: ) print(json.dumps({"release_class": release_class}, sort_keys=True)) return 0 - if args.command == "create-receipt": - create_release_receipt( + if args.command == "create-manifest": + create_release_manifest( previous_version=args.previous_version, version=args.version, tag=args.tag, @@ -544,14 +662,20 @@ def main(argv: Sequence[str] | None = None) -> int: output_path=args.output, ) return 0 - if args.command == "verify-receipt": - verify_release_receipt(args.receipt, args.artifact_dir) + if args.command == "verify-manifest": + verify_release_manifest(args.receipt, args.artifact_dir) + return 0 + if args.command == "verify-final-receipt": + verify_final_release_receipt(args.receipt) return 0 if args.attempts < 1 or args.delay_seconds < 0: raise ReleaseContractError("PyPI retry settings must be non-negative") for attempt in range(1, args.attempts + 1): try: - verify_pypi_release(args.receipt) + if args.command == "finalize-receipt": + finalize_release_receipt(args.manifest, args.output) + else: + verify_pypi_release(args.receipt) return 0 except ReleaseContractError: if attempt == args.attempts: diff --git a/tests/test_release_contract.py b/tests/test_release_contract.py index 4f3b24a..35838b5 100644 --- a/tests/test_release_contract.py +++ b/tests/test_release_contract.py @@ -14,11 +14,13 @@ from scripts.release_contract import ( ReleaseContractError, classify_commit_messages, - create_release_receipt, + create_release_manifest, + finalize_release_receipt, validate_conventional_history, validate_release_baseline, + verify_final_release_receipt, verify_pypi_release, - verify_release_receipt, + verify_release_manifest, ) @@ -147,7 +149,7 @@ def _write_dist(self, directory: Path, version: str = VERSION) -> None: package_info.unlink() def _create(self, dist: Path, output: Path, *, previous: str = "0.7.0") -> dict: - return create_release_receipt( + return create_release_manifest( previous_version=previous, version=self.VERSION, tag=f"v{self.VERSION}", @@ -160,6 +162,23 @@ def _create(self, dist: Path, output: Path, *, previous: str = "0.7.0") -> dict: output_path=output, ) + def _pypi_release(self, receipt: dict, dist: Path) -> tuple[dict, dict[str, bytes]]: + urls = [] + payloads = {} + for artifact in receipt["artifacts"]: + filename = artifact["filename"] + url = f"https://files.pythonhosted.org/packages/release/{filename}" + payloads[url] = (dist / filename).read_bytes() + urls.append( + { + "filename": filename, + "digests": {"sha256": artifact["sha256"]}, + "url": url, + "yanked": False, + } + ) + return {"info": {"version": self.VERSION}, "urls": urls}, payloads + def test_receipt_binds_artifact_metadata_and_hashes(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -197,7 +216,10 @@ def test_receipt_binds_artifact_metadata_and_hashes(self) -> None: self.assertEqual(receipt["evidence"][gate]["source_sha"], self.SOURCE_SHA) self.assertEqual(receipt["evidence"][gate]["result"], "passed") self.assertTrue(receipt["evidence"][gate]["command"]) - self.assertEqual(verify_release_receipt(root / "release-receipt.json", dist), receipt) + self.assertEqual(receipt["receipt_state"], "prepublication") + self.assertEqual( + verify_release_manifest(root / "release-receipt.json", dist), receipt + ) def test_hash_mismatch_is_rejected(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -211,7 +233,7 @@ def test_hash_mismatch_is_rejected(self) -> None: artifact.write_bytes(artifact.read_bytes() + b"tampered") with self.assertRaisesRegex(ReleaseContractError, "SHA-256 mismatch"): - verify_release_receipt(receipt_path, dist) + verify_release_manifest(receipt_path, dist) def test_major_receipt_is_never_automatic_candidate(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -220,7 +242,7 @@ def test_major_receipt_is_never_automatic_candidate(self) -> None: dist.mkdir() self._write_dist(dist, version="1.0.0") - receipt = create_release_receipt( + receipt = create_release_manifest( previous_version="0.7.0", version="1.0.0", tag="v1.0.0", @@ -260,21 +282,7 @@ def test_pypi_inventory_and_downloaded_bytes_match_receipt(self) -> None: self._write_dist(dist) receipt_path = root / "release-receipt.json" receipt = self._create(dist, receipt_path) - urls = [] - payloads = {} - for artifact in receipt["artifacts"]: - filename = artifact["filename"] - url = f"https://files.pythonhosted.org/packages/release/{filename}" - payloads[url] = (dist / filename).read_bytes() - urls.append( - { - "filename": filename, - "digests": {"sha256": artifact["sha256"]}, - "url": url, - "yanked": False, - } - ) - metadata = {"info": {"version": self.VERSION}, "urls": urls} + metadata, payloads = self._pypi_release(receipt, dist) result = verify_pypi_release( receipt_path, @@ -284,6 +292,96 @@ def test_pypi_inventory_and_downloaded_bytes_match_receipt(self) -> None: self.assertEqual(result, metadata) + def test_verified_pypi_urls_round_trip_into_final_receipt(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + dist = root / "dist" + dist.mkdir() + self._write_dist(dist) + manifest_path = root / "release-manifest.json" + manifest = self._create(dist, manifest_path) + metadata, payloads = self._pypi_release(manifest, dist) + receipt_path = root / "release-receipt.json" + + receipt = finalize_release_receipt( + manifest_path, + receipt_path, + fetch_json=lambda _url: metadata, + fetch_bytes=payloads.__getitem__, + ) + + self.assertEqual(receipt["receipt_state"], "published") + self.assertEqual( + receipt, + json.loads(receipt_path.read_text(encoding="utf-8")), + ) + verified = verify_final_release_receipt(receipt_path) + self.assertEqual(verified, receipt) + for artifact in receipt["artifacts"]: + self.assertEqual(artifact["size"], len(payloads[artifact["url"]])) + self.assertTrue( + artifact["url"].startswith("https://files.pythonhosted.org/") + ) + + def test_yanked_pypi_file_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + dist = root / "dist" + dist.mkdir() + self._write_dist(dist) + manifest_path = root / "release-manifest.json" + manifest = self._create(dist, manifest_path) + metadata, payloads = self._pypi_release(manifest, dist) + metadata["urls"][0]["yanked"] = True + + with self.assertRaisesRegex(ReleaseContractError, "is yanked"): + verify_pypi_release( + manifest_path, + fetch_json=lambda _url: metadata, + fetch_bytes=payloads.__getitem__, + ) + + def test_pypi_metadata_digest_mismatch_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + dist = root / "dist" + dist.mkdir() + self._write_dist(dist) + manifest_path = root / "release-manifest.json" + manifest = self._create(dist, manifest_path) + metadata, payloads = self._pypi_release(manifest, dist) + metadata["urls"][0]["digests"]["sha256"] = "0" * 64 + + with self.assertRaisesRegex(ReleaseContractError, "PyPI SHA-256 mismatch"): + verify_pypi_release( + manifest_path, + fetch_json=lambda _url: metadata, + fetch_bytes=payloads.__getitem__, + ) + + def test_non_pythonhosted_artifact_url_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + dist = root / "dist" + dist.mkdir() + self._write_dist(dist) + manifest_path = root / "release-manifest.json" + manifest = self._create(dist, manifest_path) + metadata, payloads = self._pypi_release(manifest, dist) + original_url = metadata["urls"][0]["url"] + invalid_url = original_url.replace( + "files.pythonhosted.org", "downloads.example.invalid" + ) + metadata["urls"][0]["url"] = invalid_url + payloads[invalid_url] = payloads[original_url] + + with self.assertRaisesRegex(ReleaseContractError, "unexpected PyPI artifact URL"): + verify_pypi_release( + manifest_path, + fetch_json=lambda _url: metadata, + fetch_bytes=payloads.__getitem__, + ) + def test_pypi_download_hash_mismatch_is_rejected(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -331,8 +429,26 @@ def test_release_is_materialized_without_push_or_vcs_release(self) -> None: self.assertIn("release-source.bundle", self.workflow) self.assertIn("git push --atomic origin", self.workflow) - def test_exact_release_source_is_tested_and_built_once(self) -> None: - self.assertIn('test "$(git rev-parse HEAD)" = "$RELEASE_SHA"', self.workflow) + def test_exact_release_source_is_tested_and_built_once_in_isolated_jobs(self) -> None: + jobs = self.release_config["jobs"] + for job_name in ("unit-public-tests", "hermes-contract", "build"): + with self.subTest(job=job_name): + job_text = yaml.safe_dump(jobs[job_name]) + self.assertIn("actions/checkout@", job_text) + self.assertIn("release-source.bundle", job_text) + self.assertIn('git rev-parse HEAD)" = "$RELEASE_SHA"', job_text) + self.assertIn("git status --porcelain", job_text) + build = jobs["build"] + self.assertEqual( + set(build["needs"]), + {"materialize", "unit-public-tests", "hermes-contract"}, + ) + build_text = yaml.safe_dump(build) + hermes_text = yaml.safe_dump(jobs["hermes-contract"]) + self.assertIn("make test-contract", hermes_text) + self.assertNotIn("make test-contract", build_text) + self.assertNotIn(".hermes-agent", build_text) + self.assertIn("make build", build_text) self.assertEqual(self.workflow.count("make build"), 1) source_push = self.workflow.index("git push --atomic origin") self.assertLess(self.workflow.index("make test"), source_push) @@ -352,7 +468,11 @@ def test_pypi_is_verified_from_registry_before_release_is_discoverable(self) -> verify = self.release_config["jobs"]["verify-pypi"] self.assertIn("publish", verify["needs"]) self.assertIn("verify-pypi", self.workflow) - self.assertIn("downloaded bytes", self.workflow) + self.assertIn("Verify PyPI bytes", self.workflow) + self.assertIn("finalize-receipt", self.workflow) + github_release = yaml.safe_dump(self.release_config["jobs"]["github-release"]) + self.assertIn("release-receipt.json", github_release) + self.assertNotIn("release-manifest.json", github_release) def test_publishing_job_has_only_oidc_write_permission(self) -> None: publish = self.release_config["jobs"]["publish"] @@ -366,10 +486,60 @@ def test_invalid_history_is_strict_and_activation_is_fail_closed(self) -> None: self.workflow, ) self.assertIn("SEMANTIC_RELEASE_ENABLED", self.workflow) + immutable_control = yaml.safe_dump( + self.release_config["jobs"]["immutable-release-control"] + ) + self.assertIn("immutable-releases", immutable_control) + self.assertIn("permission-administration: read", immutable_control) + self.assertIn("isImmutable", self.workflow) self.assertIn("validate-history", self.workflow) self.assertIn('"$version" = "$previous_version"', self.workflow) self.assertIn("Reject an already-published PyPI version", self.workflow) self.assertIn("404) ;;", self.workflow) + self.assertIn("--connect-timeout", self.workflow) + self.assertIn("--max-time", self.workflow) + + def test_source_promotion_uses_protected_github_app_identity(self) -> None: + promote = self.release_config["jobs"]["promote-source"] + promote_text = yaml.safe_dump(promote) + self.assertEqual(promote["environment"], "source-promotion") + self.assertIn( + "actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1", + promote_text, + ) + self.assertIn("SOURCE_PROMOTION_APP_CLIENT_ID", promote_text) + self.assertIn("SOURCE_PROMOTION_APP_PRIVATE_KEY", promote_text) + self.assertNotIn("github.token", promote_text) + self.assertIn("persist-credentials: 'false'", promote_text) + self.assertNotIn("personal_access_token", promote_text.lower()) + + def test_no_release_intent_never_enters_a_protected_environment(self) -> None: + jobs = self.release_config["jobs"] + materialize = jobs["materialize"] + immutable_control = jobs["immutable-release-control"] + promote = jobs["promote-source"] + + self.assertEqual(materialize["needs"], "activation") + self.assertEqual( + materialize["if"], "needs.activation.outputs.enabled == 'true'" + ) + self.assertEqual(immutable_control["needs"], "materialize") + self.assertEqual( + immutable_control["if"], + "needs.materialize.outputs.released == 'true'", + ) + self.assertIn("immutable-release-control", promote["needs"]) + self.assertIn("build", promote["needs"]) + self.assertEqual( + promote["if"], "needs.materialize.outputs.released == 'true'" + ) + + def test_every_checkout_discards_automatic_credentials(self) -> None: + checkout_count = self.workflow.count("uses: actions/checkout@") + self.assertGreater(checkout_count, 0) + self.assertEqual( + checkout_count, self.workflow.count("persist-credentials: false") + ) def test_publish_retry_never_rebuilds_and_must_reverify_registry_bytes(self) -> None: publish = self.release_config["jobs"]["publish"] @@ -378,6 +548,29 @@ def test_publish_retry_never_rebuilds_and_must_reverify_registry_bytes(self) -> self.assertEqual(self.workflow.count("make build"), 1) self.assertIn("verify-pypi", self.release_config["jobs"]) + def test_source_promotion_resumes_after_an_ambiguous_success(self) -> None: + promote = self.release_config["jobs"]["promote-source"] + push = promote["steps"][-1]["run"] + + self.assertIn( + '[ "$remote_main" = "$RELEASE_SHA" ] && ' + '[ "$remote_tag" = "$RELEASE_SHA" ]', + push, + ) + self.assertIn( + '[ "$remote_main" = "$TRIGGER_SHA" ] && [ -z "$remote_tag" ]', + push, + ) + self.assertIn("if ! git push --atomic origin", push) + self.assertGreaterEqual( + push.count('test "$remote_main" = "$RELEASE_SHA"'), + 1, + ) + self.assertGreaterEqual( + push.count('test "$remote_tag" = "$RELEASE_SHA"'), + 1, + ) + def test_actions_are_immutable_sha_pinned(self) -> None: action_lines = [ line.strip() for line in self.workflow.splitlines() if "uses:" in line From 39bcf154a8934b4b9d92a69bbb14373f0036c4f3 Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Fri, 14 Aug 2026 15:26:13 -0500 Subject: [PATCH 3/3] build: replace Make with Just Keep one repository task interface for local development, CI, and release receipts so the validation boundary cannot drift between environments. --- .github/workflows/release.yml | 20 ++++-- .github/workflows/test.yml | 22 ++++-- AGENTS.md | 6 +- Makefile | 41 ----------- README.md | 20 +++--- justfile | 66 ++++++++++++++++++ scripts/release_contract.py | 8 +-- skills/hermes-plugins/SKILL.md | 7 +- .../hermes-plugins/references/plugin-kit.md | 6 +- tests/test_release_contract.py | 69 +++++++++++++++++-- 10 files changed, 185 insertions(+), 80 deletions(-) delete mode 100644 Makefile create mode 100644 justfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 256ee3a..9419fb3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -201,6 +201,10 @@ jobs: with: python-version: "3.11" enable-cache: true + - name: Install Just + uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4 + with: + just-version: "1.58.0" - name: Download the immutable release source uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: @@ -229,7 +233,7 @@ jobs: run: | test "$(git rev-parse HEAD)" = "$RELEASE_SHA" test -z "$(git status --porcelain --untracked-files=no)" - make test + just test test "$(git rev-parse HEAD)" = "$RELEASE_SHA" test "$(git rev-parse HEAD^)" = "$TRIGGER_SHA" test "$(git rev-list -n 1 "$TAG")" = "$RELEASE_SHA" @@ -253,6 +257,10 @@ jobs: with: python-version: "3.11" enable-cache: true + - name: Install Just + uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4 + with: + just-version: "1.58.0" - name: Download the immutable release source uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: @@ -281,7 +289,7 @@ jobs: run: | test "$(git rev-parse HEAD)" = "$RELEASE_SHA" test -z "$(git status --porcelain --untracked-files=no)" - make test-contract + just test-contract git -C .hermes-agent rev-parse HEAD > hermes-source-sha.txt test "$(git rev-parse HEAD)" = "$RELEASE_SHA" test "$(git rev-parse HEAD^)" = "$TRIGGER_SHA" @@ -318,6 +326,10 @@ jobs: with: python-version: "3.11" enable-cache: true + - name: Install Just + uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4 + with: + just-version: "1.58.0" - name: Download the immutable release source uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: @@ -351,8 +363,8 @@ jobs: run: | test "$(git rev-parse HEAD)" = "$RELEASE_SHA" test -z "$(git status --porcelain --untracked-files=no)" - make build - make check-dist + just build + just check-dist test "$(git rev-parse HEAD)" = "$RELEASE_SHA" test "$(git rev-parse HEAD^)" = "$TRIGGER_SHA" test "$(git rev-list -n 1 "$TAG")" = "$RELEASE_SHA" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ae85ec1..06421b7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,8 +18,11 @@ jobs: with: python-version: "3.11" enable-cache: true + - uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4 + with: + just-version: "1.58.0" - name: Run unittest suite - run: make test + run: just test package: runs-on: ubuntu-latest @@ -29,10 +32,13 @@ jobs: with: python-version: "3.11" enable-cache: true + - uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4 + with: + just-version: "1.58.0" - name: Build and validate package metadata run: | - make build - make check-dist + just build + just check-dist # Amber admission gates on the exact deployed Hermes fork contract. Keep the # immutable revision aligned with infra's candidate image/source receipt. @@ -49,13 +55,16 @@ jobs: with: python-version: "3.11" enable-cache: true + - uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4 + with: + just-version: "1.58.0" - name: Run context-engine contract against deployed Hermes revision env: HERMES_AGENT_PATH: ${{ github.workspace }}/.hermes-agent-deployed run: | test "$(git -C "$HERMES_AGENT_PATH" rev-parse HEAD)" = \ "d3b1cfe9a80531e0682b1e66752e04cea29d5d4a" - uv run python -m unittest tests.test_context_engine_contract -v + just test-context-engine-contract # Track forward drift against upstream main without making Amber depend on # APIs that are newer than its exact deployed host. @@ -72,9 +81,12 @@ jobs: with: python-version: "3.11" enable-cache: true + - uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4 + with: + just-version: "1.58.0" - name: Run hermes contract tests against upstream main env: HERMES_AGENT_PATH: ${{ github.workspace }}/.hermes-agent run: | git -C "$HERMES_AGENT_PATH" rev-parse HEAD - uv run python -m unittest tests.test_hermes_contract -v + just test-contract diff --git a/AGENTS.md b/AGENTS.md index f464f31..4818dcc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,9 +71,9 @@ plugin. and recovery dispatch on `get_tool_schemas` / `handle_tool_call`; do not duplicate native engine tools through `@tool`. - Redact secret-looking values in logs and avoid logging full untrusted payloads. -- Use `uv` and the Makefile for local development: - `make install`, `make test`, `make test-one T=tests.test_kit.SchemaConventionTests`, - and `make build`. +- Use `uv` and the `justfile` for local development: + `just install`, `just test`, + `just test-one tests.test_kit.SchemaConventionTests`, and `just build`. ## Release Notes diff --git a/Makefile b/Makefile deleted file mode 100644 index 4f6c75b..0000000 --- a/Makefile +++ /dev/null @@ -1,41 +0,0 @@ -.DEFAULT_GOAL := help -UV ?= uv -HERMES_AGENT_REPO ?= https://github.com/NousResearch/hermes-agent.git -HERMES_AGENT_DIR ?= .hermes-agent - -.PHONY: help install test test-one test-release test-contract build check-dist clean - -help: ## Show available targets - @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \ - awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}' - -install: ## Create/sync the uv-managed environment - $(UV) sync - -test: ## Run the full unittest suite - $(UV) run python -m unittest discover -s tests - -test-one: ## Run a single test: make test-one T=tests.test_kit.Class.method - $(UV) run python -m unittest $(T) - -test-release: ## Run deterministic release intent, identity, and workflow contracts - $(UV) run python -m unittest tests.test_release_contract -v - -test-contract: ## Clone hermes-agent into a staging dir and run the contract tests against it - @if [ -d "$(HERMES_AGENT_DIR)/.git" ]; then \ - echo "Updating $(HERMES_AGENT_DIR)"; git -C "$(HERMES_AGENT_DIR)" pull --ff-only -q || true; \ - else \ - echo "Cloning hermes-agent into $(HERMES_AGENT_DIR)"; git clone --depth 1 "$(HERMES_AGENT_REPO)" "$(HERMES_AGENT_DIR)"; \ - fi - HERMES_AGENT_PATH="$(abspath $(HERMES_AGENT_DIR))" $(UV) run python -m unittest tests.test_hermes_contract -v - -build: ## Build the wheel/sdist distribution - rm -rf dist - $(UV) build - -check-dist: ## Validate wheel and sdist package metadata - $(UV) run twine check dist/* - -clean: ## Remove Python caches and build artifacts - find . -type d -name __pycache__ -prune -exec rm -rf {} + - rm -rf .pytest_cache .coverage htmlcov dist build *.egg-info diff --git a/README.md b/README.md index aff5363..02caa9b 100644 --- a/README.md +++ b/README.md @@ -716,17 +716,19 @@ so the linked directory is self-contained. ## Development -Uses [uv](https://docs.astral.sh/uv/). Install it with `brew install uv` (macOS) or -`curl -LsSf https://astral.sh/uv/install.sh | sh`. +Uses [uv](https://docs.astral.sh/uv/) and +[Just](https://github.com/casey/just). On macOS, install both with +`brew install uv just`; otherwise install them using their platform-specific +instructions. ```bash -make install # uv sync — create/sync the dev environment -make test # uv run python -m unittest discover -s tests -make test-one T=tests.test_kit.SchemaConventionTests -make test-release # release intent, artifact identity, and workflow contracts -make test-contract # real upstream Hermes contract -make build # uv build — wheel + sdist -make check-dist # validate wheel/sdist metadata with twine +just install # uv sync — create/sync the dev environment +just test # uv run python -m unittest discover -s tests +just test-one tests.test_kit.SchemaConventionTests +just test-release # release intent, artifact identity, and workflow contracts +just test-contract # real upstream Hermes contract +just build # uv build — wheel + sdist +just check-dist # validate wheel/sdist metadata with twine ``` CI pins every Action to an immutable commit and runs the unit, package metadata, diff --git a/justfile b/justfile new file mode 100644 index 0000000..0102b52 --- /dev/null +++ b/justfile @@ -0,0 +1,66 @@ +set shell := ["bash", "-cu"] + +uv := env_var_or_default("UV", "uv") +hermes_agent_repo := env_var_or_default("HERMES_AGENT_REPO", "https://github.com/NousResearch/hermes-agent.git") +hermes_agent_dir := env_var_or_default("HERMES_AGENT_DIR", ".hermes-agent") + +# Show available recipes. +default: + @just --list + +# Create or sync the uv-managed environment. +install: + {{ uv }} sync + +# Run the full unittest suite. +test: + {{ uv }} run python -m unittest discover -s tests + +# Run one unittest by dotted test name. +test-one test_name: + {{ uv }} run python -m unittest {{ test_name }} + +# Run deterministic release intent, identity, and workflow contracts. +test-release: + {{ uv }} run python -m unittest tests.test_release_contract -v + +# Prepare a local Hermes checkout unless the caller supplied one. +[private] +prepare-hermes-agent: + #!/usr/bin/env bash + set -euo pipefail + if [[ -n "${HERMES_AGENT_PATH:-}" ]]; then + exit 0 + elif [[ -d "{{ hermes_agent_dir }}/.git" ]]; then + echo "Updating {{ hermes_agent_dir }}" + git -C "{{ hermes_agent_dir }}" pull --ff-only -q || true + else + echo "Cloning hermes-agent into {{ hermes_agent_dir }}" + git clone --depth 1 "{{ hermes_agent_repo }}" "{{ hermes_agent_dir }}" + fi + +# Run contract tests against an existing or locally managed Hermes checkout. +test-contract: prepare-hermes-agent + #!/usr/bin/env bash + set -euo pipefail + hermes_path="${HERMES_AGENT_PATH:-{{ hermes_agent_dir }}}" + HERMES_AGENT_PATH="$(cd "$hermes_path" && pwd)" \ + {{ uv }} run python -m unittest tests.test_hermes_contract -v + +# Run the context-engine contract against HERMES_AGENT_PATH. +test-context-engine-contract: + {{ uv }} run python -m unittest tests.test_context_engine_contract -v + +# Build the wheel and source distribution. +build: + rm -rf dist + {{ uv }} build + +# Validate wheel and source-distribution metadata. +check-dist: + {{ uv }} run twine check dist/* + +# Remove Python caches and build artifacts. +clean: + find . -type d -name __pycache__ -prune -exec rm -rf {} + + rm -rf .pytest_cache .coverage htmlcov dist build *.egg-info diff --git a/scripts/release_contract.py b/scripts/release_contract.py index bcd1944..710c60a 100644 --- a/scripts/release_contract.py +++ b/scripts/release_contract.py @@ -292,19 +292,19 @@ def create_release_manifest( "registry_url": f"https://pypi.org/project/{PACKAGE_NAME}/{version}/", "artifacts": _artifact_records(artifact_dir, version), "evidence": { - "unit": {**passed, "command": "make test"}, + "unit": {**passed, "command": "just test"}, "public_contract": { **passed, - "command": "make test-release (included in make test)", + "command": "just test-release (included in just test)", }, "hermes_contract": { **passed, - "command": "make test-contract", + "command": "just test-contract", "hermes_source_sha": hermes_source_sha, }, "build_metadata": { **passed, - "command": "make build && make check-dist", + "command": "just build && just check-dist", }, "workflow": { "name": workflow, diff --git a/skills/hermes-plugins/SKILL.md b/skills/hermes-plugins/SKILL.md index b3c0386..0c156d2 100644 --- a/skills/hermes-plugins/SKILL.md +++ b/skills/hermes-plugins/SKILL.md @@ -75,10 +75,9 @@ For catalog or content plugins, keep public summaries from leaking raw prompts, Run the repo-native install/test commands before committing. Common examples: ```bash -make install -make test -make build -python -m unittest discover -s tests +just install +just test +just build ``` Also run focused registration, manifest-parity, hook or middleware, and local diff --git a/skills/hermes-plugins/references/plugin-kit.md b/skills/hermes-plugins/references/plugin-kit.md index 52afa38..74955c1 100644 --- a/skills/hermes-plugins/references/plugin-kit.md +++ b/skills/hermes-plugins/references/plugin-kit.md @@ -102,9 +102,9 @@ the same operations with `@tool` shadows the active-context-aware dispatch. In this repository: ```bash -make install -make test -make build +just install +just test +just build ``` In a consumer, run its native suite plus registration and manifest-parity tests. diff --git a/tests/test_release_contract.py b/tests/test_release_contract.py index 35838b5..ab9738a 100644 --- a/tests/test_release_contract.py +++ b/tests/test_release_contract.py @@ -216,6 +216,19 @@ def test_receipt_binds_artifact_metadata_and_hashes(self) -> None: self.assertEqual(receipt["evidence"][gate]["source_sha"], self.SOURCE_SHA) self.assertEqual(receipt["evidence"][gate]["result"], "passed") self.assertTrue(receipt["evidence"][gate]["command"]) + self.assertEqual(receipt["evidence"]["unit"]["command"], "just test") + self.assertEqual( + receipt["evidence"]["public_contract"]["command"], + "just test-release (included in just test)", + ) + self.assertEqual( + receipt["evidence"]["hermes_contract"]["command"], + "just test-contract", + ) + self.assertEqual( + receipt["evidence"]["build_metadata"]["command"], + "just build && just check-dist", + ) self.assertEqual(receipt["receipt_state"], "prepublication") self.assertEqual( verify_release_manifest(root / "release-receipt.json", dist), receipt @@ -445,14 +458,14 @@ def test_exact_release_source_is_tested_and_built_once_in_isolated_jobs(self) -> ) build_text = yaml.safe_dump(build) hermes_text = yaml.safe_dump(jobs["hermes-contract"]) - self.assertIn("make test-contract", hermes_text) - self.assertNotIn("make test-contract", build_text) + self.assertIn("just test-contract", hermes_text) + self.assertNotIn("just test-contract", build_text) self.assertNotIn(".hermes-agent", build_text) - self.assertIn("make build", build_text) - self.assertEqual(self.workflow.count("make build"), 1) + self.assertIn("just build", build_text) + self.assertEqual(self.workflow.count("just build"), 1) source_push = self.workflow.index("git push --atomic origin") - self.assertLess(self.workflow.index("make test"), source_push) - self.assertLess(self.workflow.index("make build"), source_push) + self.assertLess(self.workflow.index("just test"), source_push) + self.assertLess(self.workflow.index("just build"), source_push) def test_pypi_publish_precedes_discoverable_github_release_receipt(self) -> None: publish = self.workflow.index("pypa/gh-action-pypi-publish@") @@ -545,7 +558,7 @@ def test_publish_retry_never_rebuilds_and_must_reverify_registry_bytes(self) -> publish = self.release_config["jobs"]["publish"] publish_action = publish["steps"][-1] self.assertEqual(publish_action["with"]["skip-existing"], "true") - self.assertEqual(self.workflow.count("make build"), 1) + self.assertEqual(self.workflow.count("just build"), 1) self.assertIn("verify-pypi", self.release_config["jobs"]) def test_source_promotion_resumes_after_an_ambiguous_success(self) -> None: @@ -591,6 +604,48 @@ def test_test_workflow_actions_are_immutable_sha_pinned(self) -> None: config = yaml.load(self.test_workflow, Loader=yaml.BaseLoader) self.assertEqual(config["permissions"], {"contents": "read"}) + def test_workflows_install_and_use_only_just(self) -> None: + setup_just = ( + "extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3" + ) + workflows = { + "release": self.workflow, + "test": self.test_workflow, + } + for workflow_name, workflow in workflows.items(): + config = yaml.load(workflow, Loader=yaml.BaseLoader) + for job_name, job in config["jobs"].items(): + steps = job.get("steps", []) + just_steps = [ + index + for index, step in enumerate(steps) + if any( + line.strip() == "just" or line.strip().startswith("just ") + for line in step.get("run", "").splitlines() + ) + ] + if not just_steps: + continue + setup_steps = [ + index + for index, step in enumerate(steps) + if step.get("uses") == setup_just + and step.get("with", {}).get("just-version") == "1.58.0" + ] + with self.subTest(workflow=workflow_name, job=job_name): + self.assertTrue( + any(index < just_steps[0] for index in setup_steps), + "Just-using jobs must install the pinned version first", + ) + + with self.subTest(workflow=workflow_name): + self.assertNotRegex(workflow, r"(?m)^\s+make(?:\s|$)") + self.assertNotIn("run: make", workflow) + + def test_justfile_is_the_only_repository_task_runner(self) -> None: + self.assertTrue((ROOT / "justfile").is_file()) + self.assertFalse((ROOT / "Makefile").exists()) + def test_unsafe_pull_request_target_is_not_used(self) -> None: self.assertNotIn("pull_request_target", self.workflow) self.assertNotIn("pull_request_target", self.test_workflow)