diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3e1a95a..2db3a50 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,17 +1,23 @@ { "name": "template-python", - // https://mcr.microsoft.com/en-us/product/devcontainers/python/tags - "image": "mcr.microsoft.com/devcontainers/python:dev-3.13-bookworm", + // https://mcr.microsoft.com/en-us/artifact/mar/devcontainers/python/tags + "image": "mcr.microsoft.com/devcontainers/python:3-3.14-trixie", + "features": { + // Run `make ci-test-docker` and other Docker-based checks against the host daemon. + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} + }, "customizations": { "vscode": { "extensions": [ "charliermarsh.ruff", - "GitHub.copilot-chat", + "editorconfig.editorconfig", + "github.vscode-github-actions", "ms-python.mypy-type-checker", "ms-python.python" ] } }, - "postCreateCommand": "pipx install uv==0.12.19 && make install-deps-dev", + // Keep the uv version within [tool.uv] required-version in pyproject.toml. + "postCreateCommand": "pipx install uv==0.13.7 && make install-deps-dev && make install-hooks", "remoteUser": "vscode" } diff --git a/.dockerignore b/.dockerignore index e2d4424..3fa44b9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,19 +1,9 @@ -.git -.github -.venv -.coverage -.pytest_cache -.ruff_cache -.mypy_cache -dist -site -notebooks -tests -docs -.devcontainer -.vscode -*.egg-info -__pycache__ -*.py[cod] -.env -.env.* +# Allowlist: only files needed to build the image enter the build context. +* +!pyproject.toml +!uv.lock +!README.md +!LICENSE +!src/ +**/__pycache__ +**/*.py[cod] diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9b7b80c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{toml,yaml,yml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.env.template b/.env.template index 26d749b..4761817 100644 --- a/.env.template +++ b/.env.template @@ -5,3 +5,5 @@ # Project Settings PROJECT_NAME=template-python PROJECT_LOG_LEVEL=INFO +# text or json +PROJECT_LOG_FORMAT=text diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..03c52fd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.png binary +uv.lock linguist-generated=true diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..ce6c125 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default reviewers for every change. Replace with your team when adopting the template. +* @ks6088ts diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..7019f56 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,29 @@ +name: Bug report +description: Report unexpected behavior +labels: ["bug"] +body: + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Include the exact command and the first error message. + validations: + required: true + - type: textarea + id: expected + attributes: + label: What did you expect? + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: Output of `template-python --version` or the commit SHA. + validations: + required: true + - type: input + id: environment + attributes: + label: Environment + description: OS, Python version, and uv version. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..004da4a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability + url: https://github.com/ks6088ts/template-python/security/advisories/new + about: Please report vulnerabilities privately. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..80b5e76 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,18 @@ +name: Feature request +description: Propose a change or improvement +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What concrete problem would this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + description: The simplest change that solves the problem, and alternatives considered. + validations: + required: true diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 8216109..3f4ebda 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -1,22 +1,48 @@ -# https://docs.github.com/en/code-security/dependabot/ecosystems-supported-by-dependabot/supported-ecosystems-and-repositories#supported-ecosystems-and-repositories +# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference +# A cooldown delays adoption of freshly published releases to reduce supply-chain risk. version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 7 + groups: + github-actions: + patterns: ["*"] - package-ecosystem: "uv" directory: "/" schedule: interval: "weekly" - # https://docs.github.com/ja/code-security/dependabot/dependabot-version-updates/optimizing-pr-creation-version-updates#setting-up-a-cooldown-period-for-dependency-updates cooldown: - default-days: 5 + default-days: 7 + semver-major-days: 14 + groups: + python-dev: + dependency-type: "development" + update-types: ["minor", "patch"] + python-runtime: + dependency-type: "production" + update-types: ["minor", "patch"] - package-ecosystem: "pre-commit" directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 7 + groups: + pre-commit: + patterns: ["*"] - package-ecosystem: "docker" directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 7 + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..80320ff --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## Summary + + + +## Verification + + + +## Checklist + +- [ ] Checks for the changed area pass ([checks by change type](../docs/development.md#checks-by-change-type)) +- [ ] English and Japanese documentation are updated if behavior changed +- [ ] No secrets, credentials, or private configuration are included diff --git a/.github/workflows/azure-static-web-app.yaml b/.github/workflows/azure-static-web-app.yaml index 9869cc9..12a3421 100644 --- a/.github/workflows/azure-static-web-app.yaml +++ b/.github/workflows/azure-static-web-app.yaml @@ -10,24 +10,21 @@ permissions: jobs: deploy: runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Set up uv with caching enabled - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + # No cache in publishing workflows to avoid cache poisoning. + - name: Set up uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: - enable-cache: true - version: "0.12.19" - python-version: "3.13" - - name: Generate documentation - shell: bash - run: | - make install-deps-docs - make docs + enable-cache: false + - name: Build documentation + run: make ci-test-docs + # Upstream has both a v1 tag (pinned here) and a v1 branch, which zizmor reports as ambiguous. - name: Deploy - uses: Azure/static-web-apps-deploy@1a947af9992250f3bc2e68ad0754c0b0c11566c9 # v1 + uses: Azure/static-web-apps-deploy@1a947af9992250f3bc2e68ad0754c0b0c11566c9 # v1 # zizmor: ignore[ref-version-mismatch] with: azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }} action: "upload" diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml new file mode 100644 index 0000000..150f9d9 --- /dev/null +++ b/.github/workflows/codeql.yaml @@ -0,0 +1,45 @@ +name: codeql + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + schedule: + - cron: "23 4 * * 1" +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + analyze: + name: analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: + - actions + - python + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Initialize CodeQL + uses: github/codeql-action/init@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 + with: + languages: ${{ matrix.language }} + build-mode: none + queries: security-extended + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/docker-release.yaml b/.github/workflows/docker-release.yaml deleted file mode 100644 index a1f5590..0000000 --- a/.github/workflows/docker-release.yaml +++ /dev/null @@ -1,48 +0,0 @@ -name: docker-release -on: - push: - tags: - - "v*" -permissions: - contents: read -jobs: - release: - runs-on: "ubuntu-latest" - timeout-minutes: 10 - steps: - - name: Check out the repo - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - - name: Log in to Docker Hub - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - # https://github.com/docker/build-push-action/issues/42#issuecomment-915323168 - - name: Set Versions - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - id: set_version - with: - script: | - const tag = context.ref.substring(10) - const no_v = tag.replace('v', '') - const dash_index = no_v.lastIndexOf('-') - const no_dash = (dash_index > -1) ? no_v.substring(0, dash_index) : no_v - core.setOutput('tag', tag) - core.setOutput('no-v', no_v) - core.setOutput('no-dash', no_dash) - - name: Build and push tag ${{steps.set_version.outputs.no-dash}} - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - build-args: | - GIT_REVISION=${{ github.sha }} - GIT_TAG=${{steps.set_version.outputs.no-dash}} - tags: | - ${{ secrets.DOCKERHUB_USERNAME }}/template-python:${{steps.set_version.outputs.no-dash}} - ${{ secrets.DOCKERHUB_USERNAME }}/template-python:latest diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 17ee4be..e5337b2 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -10,16 +10,17 @@ on: - "main" permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: docker: runs-on: "ubuntu-latest" - timeout-minutes: 5 + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # to retrieve git tags persist-credentials: false - - name: Run CI tests for Docker - shell: bash - run: | - make ci-test-docker + - name: Lint, build, scan, and run the image + run: make ci-test-docker diff --git a/.github/workflows/ghcr-release.yaml b/.github/workflows/ghcr-release.yaml deleted file mode 100644 index 5ec4c77..0000000 --- a/.github/workflows/ghcr-release.yaml +++ /dev/null @@ -1,52 +0,0 @@ -name: ghcr -on: - push: - tags: - - "v*" -permissions: - packages: write - contents: read -env: - IMAGE_NAME: template-python -jobs: - ghcr: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Set Versions - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - id: set_version - with: - script: | - const tag = context.ref.substring(10) - const no_v = tag.replace('v', '') - const dash_index = no_v.lastIndexOf('-') - const no_dash = (dash_index > -1) ? no_v.substring(0, dash_index) : no_v - core.setOutput('tag', tag) - core.setOutput('no-v', no_v) - core.setOutput('no-dash', no_dash) - - name: Set meta data tag = ${{steps.set_version.outputs.no-dash}} - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - id: meta - with: - images: ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} - tags: | - type=semver,pattern={{version}},value=v${{steps.set_version.outputs.no-dash}} - type=raw,value=latest - - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 - with: - push: true - context: . - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/github-pages.yaml b/.github/workflows/github-pages.yaml index c1b892e..405dca1 100644 --- a/.github/workflows/github-pages.yaml +++ b/.github/workflows/github-pages.yaml @@ -4,11 +4,9 @@ on: push: branches: - main + workflow_dispatch: -permissions: - contents: read - id-token: write - pages: write +permissions: {} concurrency: group: pages @@ -21,16 +19,19 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: read + id-token: write + pages: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + # No cache in publishing workflows to avoid cache poisoning. - name: Set up uv - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: - enable-cache: true - version: "0.12.19" - python-version: "3.13" + enable-cache: false - name: Build documentation run: make ci-test-docs - name: Upload Pages artifact diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..de373d4 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,90 @@ +name: release + +on: + push: + tags: + - "v*" +permissions: {} +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Verify that the tag matches the package version + shell: bash + run: | + version=$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])') + if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then + echo "::error::Tag ${GITHUB_REF_NAME} does not match pyproject.toml version ${version} (expected v${version})" + exit 1 + fi + ghcr: + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + attestations: write + contents: read + id-token: write + packages: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up QEMU + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + # PEP 440 tags: pre-releases (e.g. v1.0.0rc1) get only their exact tag, never latest or major.minor. + - name: Generate image metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=pep440,pattern={{version}} + type=pep440,pattern={{major}}.{{minor}} + - name: Build image for scanning + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + load: true + platforms: linux/amd64 + tags: release-scan/template-python:candidate + - name: Scan image + run: make docker-scan DOCKER_REPO_NAME=release-scan DOCKER_IMAGE_NAME=template-python GIT_TAG=candidate + - name: Log in to GHCR + uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + id: push + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + provenance: mode=max + sbom: true + build-args: | + GIT_REVISION=${{ github.sha }} + GIT_TAG=${{ steps.meta.outputs.version }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} + - name: Attest build provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-name: ghcr.io/${{ github.repository }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true diff --git a/.github/workflows/scorecard.yaml b/.github/workflows/scorecard.yaml new file mode 100644 index 0000000..1b1ebd6 --- /dev/null +++ b/.github/workflows/scorecard.yaml @@ -0,0 +1,39 @@ +name: scorecard + +on: + branch_protection_rule: + push: + branches: + - "main" + schedule: + - cron: "41 5 * * 1" +permissions: {} +jobs: + analysis: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: read + contents: read + id-token: write # publish results to the OpenSSF API + security-events: write # upload SARIF to code scanning + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Run OpenSSF Scorecard + uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + - name: Upload SARIF artifact + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: scorecard-sarif + path: results.sarif + retention-days: 5 + - name: Upload SARIF to code scanning + uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 + with: + sarif_file: results.sarif diff --git a/.github/workflows/security.yaml b/.github/workflows/security.yaml new file mode 100644 index 0000000..c3c96bf --- /dev/null +++ b/.github/workflows/security.yaml @@ -0,0 +1,33 @@ +name: security + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + schedule: + # Weekly, so newly disclosed vulnerabilities are found without code changes. + - cron: "17 3 * * 1" + workflow_dispatch: +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + scan: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # scan the full Git history for secrets + persist-credentials: false + - name: Set up uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + with: + enable-cache: true + - name: Audit dependencies, secrets, and files + run: make ci-test-security diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2f9a1d3..a62574e 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -15,68 +15,69 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: - test: + quality: + name: quality gate + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # uv version comes from [tool.uv] required-version; Python from .python-version. + - name: Set up uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + with: + enable-cache: true + - name: Run Python quality gate + run: make ci-test + - name: Build documentation + run: make ci-test-docs + compatibility: + name: python ${{ matrix.python-version }} strategy: fail-fast: false matrix: python-version: - "3.12" - "3.13" - - "3.14" + - "3.15" runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Set up uv with caching enabled - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + - name: Set up uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: enable-cache: true - version: "0.12.19" python-version: ${{ matrix.python-version }} - - name: Install actionlint - if: matrix.python-version == '3.13' - shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - mkdir -p "$RUNNER_TEMP/actionlint" - gh release download v1.7.12 --repo rhysd/actionlint \ - --pattern 'actionlint_1.7.12_linux_amd64.tar.gz' \ - --pattern 'actionlint_1.7.12_checksums.txt' \ - --dir "$RUNNER_TEMP/actionlint" - (cd "$RUNNER_TEMP/actionlint" && sha256sum -c --ignore-missing actionlint_1.7.12_checksums.txt) - tar -xzf "$RUNNER_TEMP/actionlint/actionlint_1.7.12_linux_amd64.tar.gz" -C "$RUNNER_TEMP/actionlint" actionlint - echo "$RUNNER_TEMP/actionlint" >> "$GITHUB_PATH" - - name: Run CI tests - if: matrix.python-version == '3.13' - shell: bash - run: | - make ci-test - make ci-test-docs - - name: Run Python compatibility tests - if: matrix.python-version != '3.13' - shell: bash - run: | - uv sync --locked --no-dev --group test - uv run --locked --no-dev --group test pytest - hooks: + - name: Run tests + run: uv run --locked --no-dev --group test pytest + lowest: + name: lowest dependencies runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Set up uv with caching enabled - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + - name: Set up uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: enable-cache: true - version: "0.12.19" - python-version: "3.13" - - name: Set up Python 3.13 - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + - name: Run tests with lowest direct dependencies + run: make test-lowest + hooks: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - python-version: "3.13" + persist-credentials: false + - name: Set up uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + with: + enable-cache: true - name: Run configured hooks run: make hooks-check diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e7968c7..eedad3a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,16 +1,56 @@ +# Python tools run from uv.lock so hooks, Make targets, and CI use identical versions. +# Repository hooks below are updated by Dependabot (pre-commit ecosystem). repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-merge-conflict + - id: check-toml - id: check-yaml + - id: detect-private-key - id: end-of-file-fixer + - id: mixed-line-ending + args: [--fix=lf] - id: trailing-whitespace - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.17.2 + - repo: https://github.com/gitleaks/gitleaks + rev: v8.30.1 hooks: + - id: gitleaks + - repo: local + hooks: + - id: uv-lock + name: uv lock --check + entry: uv lock --check + language: system + files: ^(pyproject\.toml|uv\.lock)$ + pass_filenames: false - id: ruff-check - types_or: [python, pyi] - args: [--fix] + name: ruff check + entry: uv run --locked ruff check --fix --force-exclude + language: system + types_or: [python, pyi, jupyter] - id: ruff-format + name: ruff format + entry: uv run --locked ruff format --force-exclude + language: system + types_or: [python, pyi, jupyter] + - id: mypy + name: mypy + entry: uv run --locked mypy + language: system types_or: [python, pyi] -exclude: "generated/.*|artifacts/.*" + pass_filenames: false + - id: actionlint + name: actionlint + entry: uv run --locked actionlint + language: system + files: ^\.github/workflows/.+\.ya?ml$ + - id: zizmor + name: zizmor + entry: uv run --locked zizmor --offline --min-severity low . + language: system + files: ^\.github/ + pass_filenames: false +exclude: ^(site|dist|build)/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 20ccbd0..4d87bc2 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,7 +1,9 @@ { - "recommendations": [ - "charliermarsh.ruff", - "ms-python.mypy-type-checker", - "ms-python.python" - ] + "recommendations": [ + "charliermarsh.ruff", + "editorconfig.editorconfig", + "github.vscode-github-actions", + "ms-python.mypy-type-checker", + "ms-python.python" + ] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f79d3a8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,13 @@ +# Contributing + +Thank you for improving this project. The documentation is the single source of truth for +procedures; this page only links to it. + +1. Set up the environment with the [development guide](docs/development.md#set-up-once). +2. Decide where a change belongs with the + [engineering guide](docs/architecture/README.md#where-to-change-code). +3. Run the [checks for the changed area](docs/development.md#checks-by-change-type). +4. Update the English and Japanese documentation together when behavior changes. +5. Open a pull request using the template and describe the verification you performed. + +Report security issues privately as described in [SECURITY.md](SECURITY.md). diff --git a/Dockerfile b/Dockerfile index 82c20e6..704e75d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,37 +1,54 @@ -FROM python:3.13-slim-trixie AS build +# syntax=docker/dockerfile:1 -WORKDIR /build +# Build a virtual environment from uv.lock; hashes in the lock file are verified. +FROM python:3.14-slim-trixie@sha256:9aaa474097d094040649f2162a627c0049b408f52138d980916788d80f04802f AS build + +COPY --from=astral/uv:0.13.7@sha256:96f40d3904aad8d0bd010539001d36c8e2188b981e1334471a90c4a1d93bb490 /uv /usr/local/bin/uv + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never \ + UV_PROJECT_ENVIRONMENT=/app/.venv + +WORKDIR /src + +# Install dependencies first so this layer is reused until the lock file changes. +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --no-dev --no-install-project -COPY --from=astral/uv:0.12.19 /uv /usr/local/bin/uv COPY pyproject.toml uv.lock README.md LICENSE ./ COPY src ./src +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-dev --no-editable -RUN uv export \ - --locked \ - --no-dev \ - --no-emit-project \ - --no-hashes \ - --output-file /dist/requirements.txt \ - && uv build --wheel --out-dir /dist - -FROM python:3.13-slim-trixie +FROM python:3.14-slim-trixie@sha256:9aaa474097d094040649f2162a627c0049b408f52138d980916788d80f04802f ARG GIT_REVISION="0000000" ARG GIT_TAG="0.0.0" -LABEL org.opencontainers.image.source="https://github.com/ks6088ts/template-python" \ +LABEL org.opencontainers.image.title="template-python" \ + org.opencontainers.image.description="A minimal, typed Python CLI template" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.source="https://github.com/ks6088ts/template-python" \ org.opencontainers.image.revision="${GIT_REVISION}" \ org.opencontainers.image.version="${GIT_TAG}" -RUN groupadd --system --gid 10001 app \ - && useradd --system --uid 10001 --gid 10001 --home-dir /app app +# Apply Debian security fixes published after the base image was built, and remove pip +# (and its vendored libraries) because the runtime never installs packages. +RUN apt-get update \ + && apt-get upgrade --yes --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* \ + && python -m pip uninstall --yes --root-user-action=ignore pip \ + && groupadd --system --gid 10001 app \ + && useradd --system --uid 10001 --gid 10001 --no-create-home --home-dir /app app + +COPY --from=build --chown=root:root /app/.venv /app/.venv -COPY --from=build /dist/requirements.txt /tmp/requirements.txt -COPY --from=build /dist/*.whl /tmp/ -RUN pip install --no-cache-dir --requirement /tmp/requirements.txt \ - && pip install --no-cache-dir --no-deps /tmp/*.whl \ - && pip uninstall --yes pip \ - && rm /tmp/*.whl /tmp/requirements.txt +ENV PATH="/app/.venv/bin:${PATH}" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 USER 10001:10001 WORKDIR /app diff --git a/Makefile b/Makefile index 5844f40..fb43f69 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,20 @@ +SHELL := bash +.SHELLFLAGS := -eu -o pipefail -c + # Git GIT_REVISION ?= $(shell git rev-parse --short HEAD) -GIT_TAG ?= $(shell git describe --tags --abbrev=0 --always | sed -e s/v//g) +GIT_TAG ?= $(shell git describe --tags --abbrev=0 --always | sed -e 's/^v//') + +# Container images used as tools. Pin by digest; refresh with +# `docker buildx imagetools inspect :`. +HADOLINT_IMAGE ?= hadolint/hadolint:v2.15.1@sha256:32dac94127fd60b7b7e3fbfc65e1383b9b5e25c9bfd7b8536de7a539fe68a12d +TRIVY_IMAGE ?= aquasec/trivy:0.76.1@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c +GITLEAKS_IMAGE ?= zricethezav/gitleaks:v8.30.1@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f +TRIVY_CACHE_VOLUME ?= template-python-trivy-cache + +# Lowest supported Python, used with the lowest declared direct dependencies. +LOWEST_PYTHON ?= 3.12 +LOWEST_VENV ?= build/venv-lowest .PHONY: help help: @@ -11,11 +25,15 @@ help: info: ## show information @echo "GIT_REVISION: $(GIT_REVISION)" @echo "GIT_TAG: $(GIT_TAG)" + @echo "PACKAGE_VERSION: $$(uv version --short)" + +# --- +# Python +# --- .PHONY: install-deps-dev install-deps-dev: ## install dependencies for development uv sync --locked --group dev - @which actionlint || echo "install actionlint https://github.com/rhysd/actionlint" .PHONY: install-hooks install-hooks: ## install Git hooks @@ -39,23 +57,29 @@ format: ## format code .PHONY: fix fix: ## apply auto-fixes - uv run --locked ruff check --fix + uv run --locked ruff check --fix . uv run --locked ruff format . .PHONY: type-check type-check: ## check types with mypy - uv run --locked mypy --config-file pyproject.toml + uv run --locked mypy .PHONY: lint -lint: type-check ## lint +lint: type-check ## lint Python code and GitHub workflows uv run --locked ruff check . - actionlint - uv run --locked zizmor --offline --strict-collection --min-severity high . + uv run --locked actionlint + uv run --locked zizmor --offline --min-severity low . .PHONY: test -test: ## run tests +test: ## run tests with coverage uv run --locked pytest +.PHONY: test-lowest +test-lowest: ## run tests with the lowest supported Python and direct dependency versions + uv venv --quiet --clear --python $(LOWEST_PYTHON) $(LOWEST_VENV) + uv pip install --quiet --python $(LOWEST_VENV) --resolution lowest-direct --group test . + $(LOWEST_VENV)/bin/python -m pytest -p no:cacheprovider + .PHONY: build build: ## build wheel and source distribution uv build --clear @@ -65,23 +89,58 @@ package-check: build ## test the built wheel and installed CLI @wheel=$$(find dist -maxdepth 1 -name '*.whl' -type f -print -quit); \ test -n "$$wheel"; \ uv run --isolated --no-project --with "$$wheel" template-python hello --name Package | grep -Fx "Hello, Package!"; \ - uv run --isolated --no-project --with "$$wheel" python -m template_python hello --name Module | grep -Fx "Hello, Module!" + uv run --isolated --no-project --with "$$wheel" python -m template_python hello --name Module | grep -Fx "Hello, Module!"; \ + uv run --isolated --no-project --with "$$wheel" template-python --version | grep -Fx "template-python $$(uv version --short)" .PHONY: hooks-check -hooks-check: ## check all configured hooks +hooks-check: ## run all configured Git hooks against all files uv run --locked prek run --all-files .PHONY: ci-test ci-test: install-deps-ci format-check lint test package-check ## run CI tests .PHONY: update -update: ## update packages +update: ## upgrade all locked packages uv lock --upgrade .PHONY: jupyterlab jupyterlab: ## run Jupyter Lab uv run --locked --no-dev --group notebook jupyter lab +# --- +# Security +# --- + +.PHONY: audit +audit: ## audit locked dependencies for known vulnerabilities + @requirements=$$(mktemp); trap 'rm -f "$$requirements"' EXIT; \ + uv export --quiet --locked --all-groups --no-emit-project --format requirements-txt --output-file "$$requirements"; \ + uv run --locked pip-audit --requirement "$$requirements" --require-hashes --disable-pip --strict --progress-spinner off + +.PHONY: secret-scan +secret-scan: ## scan the Git history for secrets (requires Docker) + docker run --rm \ + -v "$(CURDIR):/repo:ro" \ + -e GIT_CONFIG_COUNT=1 -e GIT_CONFIG_KEY_0=safe.directory -e GIT_CONFIG_VALUE_0='*' \ + $(GITLEAKS_IMAGE) git /repo --redact --no-banner --verbose + +.PHONY: security-scan +security-scan: ## scan files for vulnerabilities, misconfigurations, and secrets (requires Docker) + docker run --rm \ + -v "$(CURDIR):/src:ro" \ + -v $(TRIVY_CACHE_VOLUME):/root/.cache/trivy \ + $(TRIVY_IMAGE) fs \ + --skip-version-check \ + --scanners vuln,misconfig,secret \ + --skip-dirs /src/.venv,/src/build,/src/dist,/src/site \ + --exit-code 1 \ + --ignore-unfixed \ + --severity HIGH,CRITICAL \ + /src + +.PHONY: ci-test-security +ci-test-security: install-deps-ci audit secret-scan security-scan ## run CI security scans + # --- # Docker # --- @@ -89,11 +148,6 @@ DOCKER_REPO_NAME ?= ks6088ts DOCKER_IMAGE_NAME ?= template-python DOCKER_COMMAND ?= hello --name Container -# Tools -HADOLINT_VERSION ?= v2.15.1 -TRIVY_VERSION ?= 0.74.0 -TRIVY_CACHE_VOLUME ?= template-python-trivy-cache - .PHONY: docker-build docker-build: ## build Docker image docker build \ @@ -109,14 +163,15 @@ docker-run: ## run Docker container .PHONY: docker-lint docker-lint: ## lint Dockerfile - docker run --rm -i hadolint/hadolint:$(HADOLINT_VERSION) < Dockerfile + docker run --rm -i $(HADOLINT_IMAGE) < Dockerfile .PHONY: docker-scan docker-scan: ## scan Docker image docker run --rm \ -v /var/run/docker.sock:/var/run/docker.sock \ -v $(TRIVY_CACHE_VOLUME):/root/.cache/trivy \ - aquasec/trivy:$(TRIVY_VERSION) image \ + $(TRIVY_IMAGE) image \ + --skip-version-check \ --exit-code 1 \ --ignore-unfixed \ --severity HIGH,CRITICAL \ @@ -135,7 +190,7 @@ install-deps-docs: ## install dependencies for documentation .PHONY: docs docs: ## build documentation - uv run --locked --no-dev --group docs mkdocs build + uv run --locked --no-dev --group docs mkdocs build --strict .PHONY: docs-serve docs-serve: ## serve documentation diff --git a/README.md b/README.md index 0cb8787..234db1e 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,18 @@ [![test](https://github.com/ks6088ts/template-python/actions/workflows/test.yaml/badge.svg?branch=main)](https://github.com/ks6088ts/template-python/actions/workflows/test.yaml?query=branch%3Amain) +[![security](https://github.com/ks6088ts/template-python/actions/workflows/security.yaml/badge.svg?branch=main)](https://github.com/ks6088ts/template-python/actions/workflows/security.yaml?query=branch%3Amain) +[![codeql](https://github.com/ks6088ts/template-python/actions/workflows/codeql.yaml/badge.svg?branch=main)](https://github.com/ks6088ts/template-python/actions/workflows/codeql.yaml?query=branch%3Amain) [![docker](https://github.com/ks6088ts/template-python/actions/workflows/docker.yaml/badge.svg?branch=main)](https://github.com/ks6088ts/template-python/actions/workflows/docker.yaml?query=branch%3Amain) -[![container-release](https://github.com/ks6088ts/template-python/actions/workflows/container-release.yaml/badge.svg)](https://github.com/ks6088ts/template-python/actions/workflows/container-release.yaml) +[![release](https://github.com/ks6088ts/template-python/actions/workflows/release.yaml/badge.svg)](https://github.com/ks6088ts/template-python/actions/workflows/release.yaml) [![docs](https://github.com/ks6088ts/template-python/actions/workflows/github-pages.yaml/badge.svg)](https://github.com/ks6088ts/template-python/actions/workflows/github-pages.yaml) +[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/ks6088ts/template-python/badge)](https://scorecard.dev/viewer/?uri=github.com/ks6088ts/template-python) # template-python [日本語ガイド](docs/index.ja.md) | [Documentation](https://ks6088ts-labs.github.io/template-python) -A small, typed Python package and CLI template for starting a library or -command-line application without assembling a toolchain from scratch. +A small, typed Python CLI template with a production-grade toolchain: static +analysis, tests with coverage, vulnerability and secret scanning, hardened +containers, and CI/CD with signed releases. [Typer](https://typer.tiangolo.com/) handles the CLI, [Pydantic Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) validates configuration, and [uv](https://docs.astral.sh/uv/) manages the locked @@ -16,15 +20,12 @@ environment. Containers, notebooks, and deployment are optional. ## Quick start -Start with **Python 3.12+**, **uv 0.13.6**, and **GNU Make** for local development. -The current local environment uses **Python 3.13.12** and **uv 0.13.6**. -CI pins **uv 0.12.19** and uses **Python 3.13** for the full quality gate; -compatibility tests also cover **Python 3.12 and 3.14**. +You need **uv 0.13.x** (`>=0.13.1,<0.14`, declared in `pyproject.toml`) and +**GNU Make**. uv installs the default **Python 3.14** from `.python-version`; +Python **3.12–3.15** are supported and tested. See the [uv installation guide](https://docs.astral.sh/uv/getting-started/installation/) -if uv is not installed. Docker, actionlint, and deployment credentials are not -needed to run the example or unit tests. - -From a terminal: +if uv is not installed. Docker and deployment credentials are not needed to run +the example or the Python quality gate. ```shell git clone https://github.com/ks6088ts/template-python.git @@ -39,26 +40,23 @@ Expected output: Hello, Ada! ``` -Then run `make test`. No virtual environment activation or `.env` file is -required. The equivalent module entry point is -`uv run --locked python -m template_python hello --name Ada`. - -Check the installed uv version and the Python runtime used by the project with: +Then run `make ci-test`. No virtual environment activation or `.env` file is +required. Other entry points: ```shell -uv --version -uv run --locked python --version +uv run --locked template-python --version +uv run --locked template-python config +uv run --locked python -m template_python hello --name Ada ``` ## Choose your next task -Read only the guide needed for the change: - | I want to... | Start here | | --- | --- | | Change behavior, run checks, or resolve a setup failure | [Development guide](docs/development.md) | | Decide where code belongs or whether to add an abstraction | [Architecture and engineering guide](docs/architecture/README.md) | | Configure the CLI | [Configuration reference](docs/development.md#configuration) | +| Understand the security and release pipeline | [Security and supply chain](docs/architecture/README.md#security-and-supply-chain) | | Turn this template into a new project | [Template adoption checklist](docs/architecture/README.md#adopting-the-template) | | Preview or edit documentation | [Documentation workflow](docs/development.md#documentation) | @@ -72,10 +70,11 @@ CLI (settings + logging) -> reusable core behavior | --- | --- | | [core.py](src/template_python/core.py) | Deterministic behavior, independent of CLI and environment | | [cli.py](src/template_python/cli.py) | Commands, user-facing errors, and application setup | -| [settings.py](src/template_python/settings.py), [logging.py](src/template_python/logging.py) | Configuration validation and logging setup | +| [settings.py](src/template_python/settings.py), [log.py](src/template_python/log.py) | Configuration validation and logging setup | | [tests/](tests/) | Behavior and CLI contract tests | | [Makefile](Makefile) | Supported local and CI commands; run `make help` to list them | | [pyproject.toml](pyproject.toml), [uv.lock](uv.lock) | Dependencies, tool configuration, and resolved versions | +| [.github/](.github/) | CI/CD workflows, Dependabot, and contribution templates | | [docs/](docs/) | Task-oriented guides in English and Japanese | ## Before submitting a change @@ -83,19 +82,18 @@ CLI (settings + logging) -> reusable core behavior | Changed area | Check | | --- | --- | | Python, dependencies, or workflows | `make ci-test` | +| Dependencies | `make audit` and `make test-lowest` | | Documentation | `make ci-test-docs` | | Container assets | `make ci-test-docker` | -The Python gate requires [actionlint](https://github.com/rhysd/actionlint/blob/main/docs/install.md) -on `PATH`. The container gate requires a running Docker daemon. If a change spans -areas, run each corresponding check. See the -[development guide](docs/development.md#checks-by-change-type) for focused checks -and Git hooks. +Docker-based checks (`ci-test-docker`, `secret-scan`, `security-scan`) require a +running Docker daemon. See the +[development guide](docs/development.md#checks-by-change-type) for focused +checks and Git hooks, and [CONTRIBUTING.md](CONTRIBUTING.md) for the pull +request flow. ## Optional assets and releases -Use these only when your project needs them: - | Asset | Entry point | | --- | --- | | Container example | `make docker-build docker-run` or `docker compose up --build` | @@ -104,13 +102,16 @@ Use these only when your project needs them: | VS Code Dev Container | [.devcontainer/](.devcontainer/) | | Image publishing and site deployment | [.github/workflows/](.github/workflows/) | -No registry or cloud account is needed for local development. Remove unused -assets and their related dependencies, Make targets, workflows, and links -together; follow the [adoption checklist](docs/architecture/README.md#adopting-the-template). +No registry or cloud account is needed for local development. Tags matching +`v*` publish a multi-platform image with an SBOM and build provenance to GHCR +after verifying that the tag equals `v` + the package version. This template does +not publish to PyPI. See [delivery](docs/architecture/README.md#delivery) before +enabling releases, and remove unused assets with the +[adoption checklist](docs/architecture/README.md#adopting-the-template). + +## Security -Tags matching `v*` publish images to Docker Hub and GHCR only after checking that -the tag matches the package version. This template does not publish to PyPI. -See [delivery rules](docs/architecture/README.md#delivery) before enabling releases. +Report vulnerabilities privately; see [SECURITY.md](SECURITY.md). ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2acae81 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,26 @@ +# Security policy + +## Supported versions + +Only the latest release and the `main` branch receive security fixes. + +## Reporting a vulnerability + +Do not open a public issue for a suspected vulnerability. Report it privately through +[GitHub private vulnerability reporting](https://github.com/ks6088ts/template-python/security/advisories/new) +and include: + +- the affected version or commit, +- steps to reproduce or a proof of concept, +- the impact you expect. + +You should receive an acknowledgement within 5 business days. Please allow time for a fix +before any public disclosure. + +## Automated safeguards + +The repository continuously runs dependency auditing (pip-audit), secret scanning +(gitleaks), file and image scanning (Trivy), static analysis (CodeQL, Ruff security rules), +workflow analysis (actionlint, zizmor), and OpenSSF Scorecard. Release images are published +with an SBOM and signed build provenance. See the +[engineering guide](docs/architecture/README.md#security-and-supply-chain) for details. diff --git a/compose.yml b/compose.yml index fa15063..fcba76b 100644 --- a/compose.yml +++ b/compose.yml @@ -3,7 +3,14 @@ services: build: context: . dockerfile: Dockerfile + image: template-python:local command: ["hello", "--name", "Compose"] environment: PROJECT_LOG_LEVEL: INFO + PROJECT_LOG_FORMAT: json PROJECT_NAME: template-python-compose + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL diff --git a/docs/architecture/README.ja.md b/docs/architecture/README.ja.md index 0c3e5ad..cef078b 100644 --- a/docs/architecture/README.ja.md +++ b/docs/architecture/README.ja.md @@ -1,165 +1,190 @@ # アーキテクチャとエンジニアリングガイド -コードの置き場所、依存関係の追加、配布アセットの変更を判断するときに使います。 -実行手順とトラブル対応は[開発ガイド](../development.ja.md)を参照してください。 +コードの配置、依存関係の追加、配布用アセットの変更を判断するときに使います。 +実行手順やトラブルシューティングは[開発ガイド](../development.ja.md)を参照してください。 -## 既定の設計 +## 標準の設計 { #default-design } -1つのインストール可能なパッケージと、薄いアプリケーション境界で構成します。 +このリポジトリは、インストール可能なパッケージ 1 つと、薄いアプリケーション境界 1 つで +構成されています。 ```text -CLI(Typer、設定、ログ) -> コアロジック(値と具体的な例外) +CLI(Typer、設定、ログ) -> コアの処理(値と具体的な例外) ``` -`src/template_python/core.py` は、環境変数、ログ、CLI の副作用を持たない決定的な -処理を担当します。`cli.py` が設定を読み、ログを構成してコアを呼び出します。 -`__init__.py` は CLI を import せず再利用可能な処理を公開し、 -`__main__.py` は起動だけを担当します。 +`src/template_python/core.py` には、環境、ログ、CLI の副作用を持たない決定的な処理を +置きます。`cli.py` は Typer のコールバックで設定を一度だけ読み込み、ログを構成し、 +設定を Click のコンテキスト(`ctx.obj`)に格納してコマンドへ渡し、コアを呼び出します。 +`__init__.py` は CLI を import せずに再利用可能な処理と `__version__` を公開し、 +`__main__.py` はガード付きのエントリーポイントに限定します。 -この分離により、ライブラリの利用者は関数を呼ぶだけなら、Typer、環境変数の -読み込み、プロセス全体のログ設定を理解する必要がありません。 +これにより、ライブラリの利用者は関数を呼ぶだけのために Typer、環境変数の読み込み、 +プロセス全体のログ設定を理解する必要がなくなります。 - +## 変更箇所の早見表 { #where-to-change-code } -## 変更箇所の早見表 - -| 変更したいこと | 主な変更先 | テストする契約 | +| 変更内容 | 主な変更箇所 | テストする契約 | | --- | --- | --- | -| 再利用可能な処理 | `src/template_python/core.py` または目的の明確な新モジュール | `tests/test_core.py` または対応する新テストで、値・正規化・具体的な例外を確認 | -| コマンド・オプション・利用者向けエラー | `src/template_python/cli.py` | `tests/test_cli.py` で stdout・stderr・終了コードを確認 | -| 環境変数による設定 | `src/template_python/settings.py` と `.env.template` | `tests/test_settings.py` で既定値・許容値・拒否を確認 | -| ログ設定 | `src/template_python/logging.py` | `tests/test_logging.py` でレベル検証・出力を確認 | -| ライブラリの公開 API | `src/template_python/__init__.py` | CLI を初期化せず公開 API を import・実行できることを確認 | -| ビルド・依存関係 | `pyproject.toml` と `uv.lock` | `make package-check` でインストール済み wheel の動作を確認 | +| 再利用する処理 | `src/template_python/core.py` または責務を絞った新しいモジュール | 値、正規化、具体的な例外。`tests/test_core.py` または対応する新しいテスト | +| コマンド、オプション、利用者向けエラー | `src/template_python/cli.py` | 標準出力、標準エラー出力、終了コード。`tests/test_cli.py` | +| 環境設定 | `src/template_python/settings.py` と `.env.template` | 既定値、有効な値、優先順位、拒否。`tests/test_settings.py` | +| ログ設定 | `src/template_python/log.py` | レベルと形式の検証、出力されるレコード。`tests/test_log.py` | +| 公開ライブラリ API | `src/template_python/__init__.py` | CLI を初期化せずに公開 API を import して呼べること | +| ビルドや依存関係の変更 | `pyproject.toml` と `uv.lock` | `make package-check` でインストール後の wheel、`make test-lowest` で下限を確認 | -## 認知負荷を小さく保つ +## 認知負荷を小さく保つ { #keep-the-cognitive-cost-small } -既定の方針には理由があります。将来必要かもしれないものではなく、具体的な要求に -応じて変更します。 +既定の方針は意図的に選んでいます。将来の可能性ではなく、具体的な要件があるときに +変更してください。 -| 判断 | まず選ぶもの | 見直す条件 | +| 判断 | 既定の方針 | 見直す条件 | +| --- | --- | --- | +| 新しい処理の置き場所 | 責務を絞ったモジュールに、名前付きの関数として置く | 具体的な外部 I/O の要件があり、アプリケーションのアダプターが必要になった | +| コードを共通化するか | 局所に置き、責務が分かる名前を付ける | 複数の実際の呼び出し元で、同じ安定した動作が必要になった | +| クラスやフレームワークを追加するか | 関数と明示的な引数を優先する | 状態やライフサイクルの管理が必要で、単純な組み合わせでは足りない | +| 依存関係やツールを追加するか | 標準ライブラリと既存のツールを使う | 明確で測定できる不足があり、実行時と保守のコストに見合う | +| 任意のアセットを追加するか | 使わないなら置かない、または削除する | 実際の配布や開発の要件で必要になった | + +- 汎用的な `utils` / `common`、基底クラスの階層、サービスロケーター、 + 先回りした DI フレームワークは避けます。 +- 標準ライブラリと同じ名前のモジュールは作りません(ログ設定は `logging.py` ではなく + `log.py` に置いています)。 +- 入力、出力、責務が分かる名前を使います。小さな処理を理解するために、 + 何層もたどる必要がないようにします。 +- 値を返すか、具体的な例外を送出します。失敗を成功に見える既定値に変えたり、 + エラーを広く握りつぶしたり、シークレットをログに出したりしないでください。 +- 各作業の正式な手順は 1 か所にまとめます。複数のドキュメントやワークフローに + コマンドをコピーせず、リンクします。 + +自明でない判断は、問題、最も単純な代替案、選んだトレードオフ、検証方法を +プルリクエストの説明に記載します。別の設計ドキュメントは、その理由を変更後も +長く残す必要がある場合にだけ作成します。 + +## 技術選定 { #technology-choices } + +広く使われ、継続的に保守され、インターフェースが安定したツールを選んでいます。 +バージョンは現在のメジャー(0.x のツールはマイナー)までに制限し、`uv.lock` で +解決します。 + +| 関心事 | 選定 | 理由 | | --- | --- | --- | -| 新しい処理の置き場所 | 目的の明確なモジュールの名前付き関数 | 外部 I/O の具体的な要求によりアプリケーション側の adapter が必要 | -| 共通化するか | 責務が分かる名前で局所的に実装 | 複数の実際の利用箇所で同じ安定した振る舞いが必要 | -| class・framework を追加するか | 関数と明示的な引数 | 状態や lifecycle の所有者が必要で、単純な組み合わせでは不足 | -| 依存関係・ツールを追加するか | 標準ライブラリと既存ツール | 実行時・保守時のコストに見合う、測定可能な不足がある | -| 任意アセットを使うか | 未使用のままにするか削除 | 実際の配布・開発要件で必要 | - -- `utils`・`common` の寄せ集め、基底クラス階層、サービスロケータ、 - 将来を想像した DI framework は避けます。 -- 入出力と責務を表す名前を使います。小さな処理を理解するために、何層ものコードを - 追う必要がない構造にします。 -- 値を返すか、具体的な例外を送出します。失敗を成功に見える既定値へ置き換えたり、 - 広く握りつぶしたり、秘密情報をログへ出したりしません。 -- 手順の正本を1つにします。複数の文書や Workflow へコマンドをコピーせず、 - 参照・再利用します。 - -自明ではない判断は、問題、最も単純な代替案、選んだトレードオフ、検証方法を -PR の説明に残します。別の設計文書は、その変更後も理由を参照する必要があるとき -だけ作ります。 - -## 情報の正本 - -| 対象 | 正本 | +| 環境、ロック、ビルド | uv、`uv_build` | Python、仮想環境、ロック、ビルドを 1 つで扱い、ハッシュ付きで再現できる | +| CLI | Typer(Click ベース) | 型ヒント駆動で、成熟した Click の上に構築されている | +| 設定 | Pydantic Settings | 型付きで検証された環境変数と `.env` の読み込み | +| 整形と Lint | Ruff | フォーマッターと、bandit(`S`)のセキュリティルールを含む幅広いルール | +| 型 | mypy(strict)と Pydantic プラグイン | 基準となる型チェッカー | +| テスト | pytest(strict モード)、pytest-cov | 事実上の標準 | +| Git フック | prek(pre-commit 互換) | `.pre-commit-config.yaml` を読み、別の Python 環境が不要 | +| 依存関係の監査 | pip-audit | PyPA が保守。`uv audit` はまだ experimental | +| シークレットスキャン | gitleaks | フックと履歴全体のスキャン | +| ファイルとイメージのスキャン | Trivy(イメージのダイジェストを固定) | ロックファイル、Dockerfile、シークレット、OS パッケージ | +| ワークフローの解析 | actionlint、zizmor、CodeQL(`actions`) | 構文、セキュリティ、データフローの検査 | +| ドキュメント | MkDocs + Material | 安定していて広く使われている。Material は 2027-05-05 にサポートが終了する(それまでは重大な修正が提供される)。終了日までに、`mkdocs.yml` をそのまま読める後継の [Zensical](https://zensical.org/docs/compatibility/mkdocs/migration/) への移行を計画する | + +## 正本の一覧 { #sources-of-truth } + +| 関心事 | 正本 | | --- | --- | | メタデータ、依存関係、ツール設定 | `pyproject.toml` | -| 解決済み依存バージョン | `uv.lock` | +| 解決済みの依存関係とツールのバージョン | `uv.lock` | +| uv のバージョン範囲 | `pyproject.toml` の `[tool.uv] required-version`(CI の `setup-uv` も参照) | +| 既定の Python / サポートするバージョン | `.python-version` / `requires-python`、classifiers、`.github/workflows/test.yaml` | | ローカルと CI のコマンド | `Makefile` | -| 最低 Python・テスト対象バージョン | `pyproject.toml`・`.github/workflows/test.yaml` | -| 設定の schema・設定例 | `src/template_python/settings.py`・`.env.template` | -| コンテナ動作 | `Dockerfile` と `compose.yml` | -| ドキュメント構成 | `mkdocs.yml` | - -宣言と、その宣言から生成するものは一緒に更新します。依存関係は -`pyproject.toml` で変更し、`uv lock` で lock を再生成します。 -CI は同じ検証を独自に実装せず、Make target を呼びます。 - -## ランタイムのルール - -- パッケージの import 時にログ設定や環境変数読み込みを行いません。 -- コアは Typer や設定に依存せず、CLI が値と例外を表示・終了コードへ変換します。 -- `ProjectSettings` が `PROJECT_*` と任意の `.env` を一元管理します。 - 別の dotenv loader を追加しません。 -- root logger の設定は CLI callback で一度だけ行い、ライブラリは名前付き - logger を取得するだけにします。 -- CLI は `[project.scripts]` で公開し、`python -m template_python` も維持します。 - -## 品質ゲート - -[変更内容に応じた検証](../development.ja.md#checks-by-change-type)を選びます。 -Python の品質ゲートは、整形、型、Lint、Workflow のルール、全テストを確認し、 -branch coverage **95% 以上**を要求します。wheel と sdist をビルドした後、 -wheel の CLI とモジュール起動を隔離環境で確認します。 -sdist のインストール試験は行いません。 - -内部構造ではなく、失敗時を含む観測可能な契約をテストします。互換性を壊す必要が -明示されていない限り、stdout、stderr、終了コード、import 時の動作を保ちます。 - -## 配布 - -- Pull Request と対象ブランチで Python とコンテナを検証します。 -- `main` から GitHub Pages へ英日ドキュメントを公開します。 -- `v*` tag から Docker Hub と GHCR へ multi-platform image を公開します。 -- GHCR release には push 済み digest に対応する provenance を付与します。 -- Azure Static Web Apps は手動デプロイを維持します。 - -Release Workflow は、tag が `v` と `pyproject.toml` の version を連結した値に -一致することを確認してから公開します。Docker Hub には -`DOCKERHUB_USERNAME` と `DOCKERHUB_TOKEN` の secret が必要です。 -GHCR は Workflow の GitHub token を使います。手動のサイト公開には -`AZURE_STATIC_WEB_APPS_API_TOKEN` が必要です。これらの認証情報はローカル開発に -不要です。PyPI への公開は設定されていません。 - -外部 Action は commit SHA で固定します。Workflow は明示的な最小権限、 -checkout credential の無効化、timeout を使い、自動キャンセルは後続実行で -置き換えられる release 以外の処理に限定します。 - - - -## テンプレートの導入 - -新しいプロジェクトを作ったときに一度行い、公開を有効にする前に確認します。 - -1. `pyproject.toml` の配布名、説明、version、URL、console script を設定し、 - ライセンスが適切か確認します。 -2. import 名を変えるなら `src/template_python/` を改名し、import、テストの参照、 - script の参照先、モジュール起動コマンド、coverage などのツール設定も揃えます。 - 型付きパッケージの `py.typed` は維持します。 -3. 設定の既定値、`.env.template`、Notebook の例、Makefile の配布物検証コマンドと - image の既定値、Dockerfile の起動先と label、Compose、Dev Container 名、 - docs の URL、Workflow の image 名、README の badge を更新します。 - 配布名はハイフン、Python の import 名はアンダースコアを使います。 -4. 次の表に従い不要なアセットを外し、実行できない CI の参照を残しません。 -5. `uv lock` を実行し、残したアセットを検証します。ソースの直接 import だけでなく、 - 改名後のインストール済み CLI を確認します。 -6. 残した配布 Workflow にだけ、リポジトリの公開設定と secret を用意します。 - release tag とパッケージ version を一致させます。 - -### 任意アセット - -Notebook、docs、コンテナ、registry、Azure、Dev Container は独立したサンプルです。 -別のアプリケーション動作を作らず、インストール済みパッケージを利用します。 - -| 外すアセット | 同時に削除・更新するもの | -| --- | --- | -| Notebook | `notebooks/`、`notebook` 依存グループ、`jupyterlab` Make target | -| ドキュメントサイト | `docs/`、`mkdocs.yml`、`docs` グループと Make target、両サイト公開 Workflow、`test.yaml` の docs コマンド | -| コンテナ | Dockerfile、Compose、`.dockerignore`、Docker の Make target・変数、コンテナ検証・公開 Workflow | -| registry 公開だけ | 不要な job または container-release Workflow。必要ならローカルのコンテナ検証は残す | -| 手動のサイト公開だけ | `azure-static-web-app.yaml` とデプロイの説明 | -| Dev Container | `.devcontainer/` と環境構築の説明 | - -依存グループを変更したら lock を再生成し、badge、リンク、ガイドの項目も同じ変更で -整理します。認証情報を要求する未使用の Workflow を残さないでください。 - -多言語出力が MkDocs 1 の plugin ecosystem に依存する間、MkDocs は 2 未満に -固定します。 - -## 変更チェックリスト - -- [ ] 変更の目的が1つで、上記のモジュール境界を守っている。 -- [ ] 期待する値と失敗をテストし、互換性を変える場合は明示している。 -- [ ] 依存宣言、lock、影響する配布アセットが一致している。 -- [ ] コマンドは既存 Make target を再利用し、新ツールは実際の不足を補っている。 -- [ ] 影響する英日ドキュメントを更新し、手順を重複させていない。 -- [ ] 変更した各領域の検証が成功し、必要なインストール済みパッケージの確認も行った。 +| Git フック | `.pre-commit-config.yaml` | +| 設定スキーマ / 上書き例 | `src/template_python/settings.py` / `.env.template` | +| コンテナの動作 | `Dockerfile`、`.dockerignore`、`compose.yml` | +| 依存関係の更新方針 | `.github/dependabot.yaml` | +| ドキュメントのナビゲーション | `mkdocs.yml` | + +宣言と、そこから生成されるアセットは一緒に更新します。依存関係の宣言は +`pyproject.toml` で変更し、`uv lock` で `uv.lock` を再生成します。CI は同じ検証を +別に実装せず、Make ターゲットを呼び出します。 + +## 実行時のルール { #runtime-rules } + +- 公開パッケージは、ログ設定や環境変数の読み込みを行わずに import できる状態を + 保ちます。 +- コアモジュールは Typer や設定に依存しません。CLI がコアの値と例外を、利用者向けの + 出力と終了コードに変換します。 +- `PROJECT_*` 環境変数と任意の `.env` の読み込みは `ProjectSettings` が担当します。 + 2 つ目の dotenv ローダーは追加しないでください。コマンドは設定を再度読み込まず、 + コンテキストから取得します。 +- ルートロガーの設定は CLI のコールバックで一度だけ行います。ライブラリのモジュールは + 名前付きロガーを取得するだけにします。ログを収集する環境では + `PROJECT_LOG_FORMAT=json` を使います。 +- シークレットは `SecretStr` で宣言します。Typer の整形された例外表示ではローカル + 変数を表示しないため、トレースバックからシークレットが漏れません。 +- コマンドは `[project.scripts]` で公開し、`python -m template_python` は補助的な + 実行方法として維持します。 + +## 品質ゲート { #quality-gate } + +[変更内容ごとの検証](../development.ja.md#checks-by-change-type)に従います。 +Python の検証では、整形、strict な型、拡張した Ruff のルール(bandit のセキュリティ +ルールを含む)、ワークフローのルール、全テストの分岐カバレッジ 95% 以上を確認します。 +さらに wheel と sdist をビルドし、インストールした wheel の CLI、モジュール実行、 +`--version` を隔離環境で実行します。sdist からのインストールは検証しません。 + +内部構造に結びついたテストではなく、失敗時を含む外から観測できる契約をテストします。 +明示的に互換性を破る変更でない限り、標準出力、標準エラー出力、終了コード、 +import 時の動作を維持します。 + +## セキュリティとサプライチェーン { #security-and-supply-chain } + +| 層 | 対策 | 場所 | +| --- | --- | --- | +| ソースコード | Ruff の `S` ルール、CodeQL(`python`、`actions`、security-extended) | `make lint`、`codeql.yaml` | +| シークレット | gitleaks のフック、履歴全体のスキャン、Trivy のシークレットスキャナー、`detect-private-key` | フック、`make secret-scan`、`make security-scan` | +| 依存関係 | ロック済みの全パッケージを pip-audit で監査(ハッシュ検証)、Trivy による `uv.lock` のスキャン、クールダウン付きの Dependabot | `make audit`、`make security-scan`、`dependabot.yaml` | +| ワークフロー | Actions を SHA で固定、最小限の `permissions`、`persist-credentials: false`、公開用ワークフローではキャッシュを使わない、actionlint と zizmor | `.github/workflows/`、`make lint` | +| コンテナ | ベースイメージと uv イメージをダイジェストで固定、ハッシュ付きロックから `uv sync --locked`、非 root ユーザー、実行イメージに pip を含めない、hadolint、Trivy によるイメージのスキャン | `Dockerfile`、`make ci-test-docker` | +| リリース | タグとバージョンの一致確認、公開前のスキャン、SBOM と max モードのプロベナンス、GitHub の artifact attestation | `release.yaml` | +| 全体の健全性 | OpenSSF Scorecard を毎週公開 | `scorecard.yaml` | + +`security.yaml` はプルリクエスト、`main`、毎週のスケジュールで +`make ci-test-security` を実行します。新しく公開された脆弱性は、コードを変更しなくても +CI の失敗として検出されます。 + +## 配布 { #delivery } + +- プルリクエストと対象ブランチでは、Python、セキュリティ、CodeQL、コンテナの検証を + 実行します。 +- `main` では、英語と日本語のドキュメントを GitHub Pages の成果物として公開します。 +- `v*` タグでは `release.yaml` が動き、マルチプラットフォーム(`linux/amd64`、 + `linux/arm64`)のイメージを GHCR に公開します。 +- Azure Static Web Apps へのデプロイは手動のままです。 + +リリースワークフローは、まずタグが `v` と `pyproject.toml` のバージョンを連結したものと +完全に一致することを確認します。次にイメージをビルドして Trivy でスキャンし、 +SBOM とビルドプロベナンスを付けて push し、GitHub の artifact attestation を記録します。 +タグは PEP 440 に従います。`v1.2.3` は `1.2.3`、`1.2`、`latest` を公開し、 +`v1.3.0rc1` のようなプレリリースは `1.3.0rc1` だけを公開します。イメージは次のように +検証できます。 + +```shell +gh attestation verify oci://ghcr.io/ks6088ts/template-python: --owner ks6088ts +``` + +GHCR はワークフローの `GITHUB_TOKEN` を使います。手動のサイトデプロイには +`AZURE_STATIC_WEB_APPS_API_TOKEN` が必要です。これらの認証情報はローカル開発には +不要です。PyPI と Docker Hub への公開は設定していません。 + +## テンプレートの導入 { #adopting-the-template } + +1. 配布名とパッケージ名を変更します。`pyproject.toml`、`src/`、テスト、`Makefile`、 + `Dockerfile`、`compose.yml`、ワークフロー、`mkdocs.yml`、ドキュメントに含まれる + `template-python` / `template_python` を置き換え、`uv lock` を実行します。 +2. `pyproject.toml` の URL、`.github/CODEOWNERS`、`SECURITY.md`、バッジ、 + `mkdocs.yml` のオーナー名を置き換えます。 +3. 設定のプレフィックス(`PROJECT_`)を決め、`.env.template` と設定リファレンスを + 更新します。 +4. `hello` コマンドと `core.greeting` を実際の処理とテストに置き換えます。 + `--version` と `config` は残します。 +5. 使わない任意のアセットは、依存関係、Make ターゲット、ワークフロー、リンクと一緒に + 削除します。対象は Notebook のグループと `notebooks/`、MkDocs と Pages/SWA の + ワークフロー、Dev Container、`compose.yml` です。 +6. リポジトリの設定で、private vulnerability reporting、Dependabot アラート、 + コードスキャン、`test`・`security`・`codeql`・`docker` のチェックを必須とする + ブランチ保護を有効にします。 +7. `make ci-test ci-test-docs ci-test-security ci-test-docker` を実行し、 + 最初のリリースのタグを付けます。 diff --git a/docs/architecture/README.md b/docs/architecture/README.md index ddc9bc7..85ed01c 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -4,7 +4,7 @@ Use this guide when deciding where code belongs, adding a dependency, or changin delivery assets. For executable procedures and troubleshooting, use the [development guide](../development.md). -## Default design +## Default design { #default-design } The repository has one installable package and one thin application boundary: @@ -13,25 +13,27 @@ CLI (Typer, settings, logging) -> core behavior (values and specific exceptions) ``` `src/template_python/core.py` contains deterministic behavior with no -environment, logging, or CLI side effects. `cli.py` loads settings, configures -logging, and calls the core. `__init__.py` exports reusable behavior without -importing the CLI; `__main__.py` is only an entry point. +environment, logging, or CLI side effects. `cli.py` loads settings once in the +Typer callback, configures logging, stores the settings on the Click context +(`ctx.obj`) for commands, and calls the core. `__init__.py` exports reusable +behavior and `__version__` without importing the CLI; `__main__.py` is only a +guarded entry point. This keeps library consumers from needing to understand Typer, environment loading, or process-global logging just to call a function. -## Where to change code +## Where to change code { #where-to-change-code } | Change | Primary location | Contract to test | | --- | --- | --- | | Reusable behavior | `src/template_python/core.py` or a focused new module | Values, normalization, and specific exceptions in `tests/test_core.py` or a matching new test module | | Command, option, or user-facing error | `src/template_python/cli.py` | stdout, stderr, and exit codes in `tests/test_cli.py` | -| Environment configuration | `src/template_python/settings.py` and `.env.template` | Defaults, valid values, and rejection in `tests/test_settings.py` | -| Logging setup | `src/template_python/logging.py` | Level validation and emitted messages in `tests/test_logging.py` | +| Environment configuration | `src/template_python/settings.py` and `.env.template` | Defaults, valid values, precedence, and rejection in `tests/test_settings.py` | +| Logging setup | `src/template_python/log.py` | Level/format validation and emitted records in `tests/test_log.py` | | Public library API | `src/template_python/__init__.py` | Import and call the public API without initializing the CLI | -| Build or dependency change | `pyproject.toml` and `uv.lock` | Installed wheel behavior via `make package-check` | +| Build or dependency change | `pyproject.toml` and `uv.lock` | Installed wheel behavior via `make package-check`; lower bounds via `make test-lowest` | -## Keep the cognitive cost small +## Keep the cognitive cost small { #keep-the-cognitive-cost-small } Defaults are deliberate. Change them for a concrete requirement, not a possible future one. @@ -46,6 +48,8 @@ future one. - Avoid generic `utils` / `common` buckets, base-class hierarchies, service locators, and speculative dependency-injection frameworks. +- Do not name modules after the standard library (the logging setup lives in + `log.py`, not `logging.py`). - Use names that reveal inputs, outputs, and responsibility. A reader should not need to follow several layers to understand a small operation. - Return values or raise specific exceptions. Do not turn failures into @@ -57,112 +61,129 @@ For a non-obvious decision, include the problem, simplest alternative, chosen trade-off, and verification in the PR description. A separate design document is only useful when the rationale needs to outlive the change. -## Sources of truth +## Technology choices { #technology-choices } + +The stack favors widely adopted, actively maintained tools with stable +interfaces. Versions are bounded to the current major (or minor for 0.x tools) +and resolved in `uv.lock`. + +| Concern | Choice | Why | +| --- | --- | --- | +| Environment, lock, build | uv, `uv_build` | One tool for Python, venv, locking, and builds; reproducible with hashes | +| CLI | Typer (on Click) | Type-hint driven, mature Click foundation | +| Configuration | Pydantic Settings | Validated, typed environment and `.env` loading | +| Format and lint | Ruff | Formatter plus a broad rule set including bandit (`S`) security rules | +| Types | mypy (strict) with the Pydantic plugin | Reference type checker | +| Tests | pytest (strict mode), pytest-cov | De facto standard | +| Git hooks | prek (pre-commit compatible) | Reads `.pre-commit-config.yaml`; no separate Python environment | +| Dependency audit | pip-audit | PyPA-maintained; `uv audit` is still experimental | +| Secret scan | gitleaks | Hook and full-history scan | +| File and image scan | Trivy (pinned image digest) | Lock file, Dockerfile, secrets, and OS packages | +| Workflow analysis | actionlint, zizmor, CodeQL (`actions`) | Syntax, security, and data-flow checks | +| Documentation | MkDocs + Material | Stable and widely used. Material reaches end of life on 2027-05-05 (critical fixes until then); plan a migration to its successor [Zensical](https://zensical.org/docs/compatibility/mkdocs/migration/), which reads `mkdocs.yml`, before that date | + +## Sources of truth { #sources-of-truth } | Concern | Source | | --- | --- | | Metadata, dependencies, tool configuration | `pyproject.toml` | -| Resolved dependency versions | `uv.lock` | +| Resolved dependency and tool versions | `uv.lock` | +| uv version range | `[tool.uv] required-version` in `pyproject.toml` (also read by `setup-uv` in CI) | +| Default Python / supported versions | `.python-version` / `requires-python`, classifiers, and `.github/workflows/test.yaml` | | Local and CI commands | `Makefile` | -| Minimum Python / tested versions | `pyproject.toml` / `.github/workflows/test.yaml` | +| Git hooks | `.pre-commit-config.yaml` | | Configuration schema / example overrides | `src/template_python/settings.py` / `.env.template` | -| Container behavior | `Dockerfile` and `compose.yml` | +| Container behavior | `Dockerfile`, `.dockerignore`, and `compose.yml` | +| Dependency update policy | `.github/dependabot.yaml` | | Documentation navigation | `mkdocs.yml` | Update declarations and derived assets together. Dependency declarations change in `pyproject.toml`; regenerate `uv.lock` with `uv lock`. CI should call Make targets instead of maintaining another implementation of the same checks. -## Runtime rules +## Runtime rules { #runtime-rules } - The public package must remain importable without configuring logging or reading environment variables. - Core modules do not depend on Typer or settings. The CLI translates their values and exceptions into user-facing output and exit codes. - `ProjectSettings` owns `PROJECT_*` environment and optional `.env` loading. - Do not add a second dotenv loader. + Do not add a second dotenv loader. Commands read settings from the context + rather than loading them again. - Configure root logging once in the CLI callback. Library modules only obtain - named loggers. + named loggers. Use `PROJECT_LOG_FORMAT=json` where logs are collected. +- Declare secrets as `SecretStr`. Typer's pretty exceptions do not show local + variables, so tracebacks do not leak secret values. - Expose commands through `[project.scripts]`; keep `python -m template_python` as a convenience path. -## Quality gate +## Quality gate { #quality-gate } Use the [checks by change type](../development.md#checks-by-change-type). -The Python gate checks formatting, types, lint, workflow rules, and all tests -with branch coverage of at least 95%. It builds wheel and sdist, then runs the -installed wheel's CLI and module entry point in isolation. It does not smoke-test -the sdist installation. +The Python gate checks formatting, strict types, an extended Ruff rule set +(including bandit security rules), workflow rules, and all tests with branch +coverage of at least 95%. It builds wheel and sdist, then runs the installed +wheel's CLI, module entry point, and `--version` in isolation. It does not +smoke-test the sdist installation. Test observable contracts, including failure paths, instead of coupling tests to internal structure. Keep stdout, stderr, exit codes, and import-time behavior stable unless the change explicitly requires a compatibility break. -## Delivery +## Security and supply chain { #security-and-supply-chain } + +| Layer | Control | Where | +| --- | --- | --- | +| Source | Ruff `S` rules, CodeQL (`python`, `actions`, security-extended) | `make lint`, `codeql.yaml` | +| Secrets | gitleaks hook, full-history scan, Trivy secret scanner, `detect-private-key` | Hooks, `make secret-scan`, `make security-scan` | +| Dependencies | pip-audit on every locked package (hash-verified), Trivy on `uv.lock`, Dependabot with cooldown | `make audit`, `make security-scan`, `dependabot.yaml` | +| Workflows | Actions pinned by SHA, minimal `permissions`, `persist-credentials: false`, no cache in publishing workflows, actionlint and zizmor | `.github/workflows/`, `make lint` | +| Container | Digest-pinned base and uv images, `uv sync --locked` from hashed lock, non-root user, no pip in the runtime image, hadolint, Trivy image scan | `Dockerfile`, `make ci-test-docker` | +| Release | Tag/version check, pre-release scan, SBOM and max-mode provenance, GitHub artifact attestation | `release.yaml` | +| Posture | OpenSSF Scorecard published weekly | `scorecard.yaml` | + +`security.yaml` runs `make ci-test-security` on pull requests, on `main`, and +weekly, so newly disclosed vulnerabilities fail CI without a code change. -- Pull requests and supported branches run Python and container validation. +## Delivery { #delivery } + +- Pull requests and supported branches run Python, security, CodeQL, and + container validation. - `main` publishes the bilingual documentation with GitHub Pages artifacts. -- `v*` tags publish multi-platform images to Docker Hub and GHCR. -- GHCR releases include build provenance tied to the pushed digest. +- `v*` tags run `release.yaml`, which publishes a multi-platform + (`linux/amd64`, `linux/arm64`) image to GHCR. - Azure Static Web Apps deployment remains manual. -The release workflow verifies that the tag is exactly `v` followed by the -`pyproject.toml` version before publishing. It requires `DOCKERHUB_USERNAME` and -`DOCKERHUB_TOKEN` secrets for Docker Hub; GHCR uses the workflow's GitHub token. -The manual site workflow requires `AZURE_STATIC_WEB_APPS_API_TOKEN`. These -credentials are not required for local development. PyPI publishing is not -configured. - -Third-party actions are pinned by commit SHA. Workflows use explicit minimum -permissions, disabled checkout credentials, bounded timeouts, and cancellation -only for superseded non-release runs. - -## Adopting the template - -Do this once when creating a project, before enabling publishing. - -1. Set the project identity: distribution name, description, version, URLs, and - console script in `pyproject.toml`. Confirm the license is appropriate. -2. If renaming the import package, rename `src/template_python/` and update - imports, test references, the script target, module-entry commands, and tool - paths such as coverage. Preserve `py.typed` for the typed package. -3. Update settings defaults, `.env.template`, notebook examples, Makefile smoke - commands and image defaults, Dockerfile entry point and labels, Compose, - Dev Container name, documentation URLs, workflow image names, and README - badges. Distribution names use hyphens; Python import names use underscores. -4. Remove unneeded assets using the table below; do not leave dead CI references. -5. Run `uv lock` and checks for the retained assets. Verify the renamed installed - CLI, not just a direct source import. -6. Configure repository publishing settings and secrets only for retained - delivery workflows. Match the package version to the release tag. - -### Optional assets - -Notebook, docs, containers, registries, Azure, and the Dev Container are -independent examples. They must consume the installed package rather than -creating alternative application behavior. - -| Remove this asset | Also remove or update | -| --- | --- | -| Notebook | `notebooks/`, the `notebook` dependency group, and `jupyterlab` Make target | -| Documentation site | `docs/`, `mkdocs.yml`, the `docs` group and Make targets, both site deployment workflows, and the docs command in `test.yaml` | -| Containers | Dockerfile, Compose, `.dockerignore`, Docker Make targets/variables, and container validation/release workflows | -| Registry publishing only | Unneeded jobs or the container-release workflow; keep local container checks if still useful | -| Manual site deployment only | `azure-static-web-app.yaml` and deployment references | -| Dev Container | `.devcontainer/` and setup references | - -In the same change, regenerate the lock file if groups changed and clean up -badges, links, and guide entries. Avoid keeping an unused workflow that requires -credentials. - -MkDocs is intentionally constrained below version 2 while multilingual output -depends on the MkDocs 1 plugin ecosystem. - -## Change checklist - -- [ ] The change has one clear purpose and uses the module boundaries above. -- [ ] Tests cover the expected values and failures; compatibility changes are explicit. -- [ ] Dependency declarations, lock file, and affected delivery assets agree. -- [ ] Commands reuse existing Make targets; any new tool fills a demonstrated gap. -- [ ] Affected English and Japanese documentation is updated without duplicating procedures. -- [ ] Checks for each changed area pass, including installed-package checks where relevant. +The release workflow first verifies that the tag is exactly `v` followed by the +`pyproject.toml` version. It builds and scans an image with Trivy, then pushes +with an SBOM and build provenance and records a GitHub artifact attestation. +Tags follow PEP 440: `v1.2.3` publishes `1.2.3`, `1.2`, and `latest`; a +pre-release such as `v1.3.0rc1` publishes only `1.3.0rc1`. Verify an image with: + +```shell +gh attestation verify oci://ghcr.io/ks6088ts/template-python: --owner ks6088ts +``` + +GHCR uses the workflow's `GITHUB_TOKEN`. The manual site workflow requires +`AZURE_STATIC_WEB_APPS_API_TOKEN`. These credentials are not required for local +development. PyPI and Docker Hub publishing are not configured. + +## Adopting the template { #adopting-the-template } + +1. Rename the distribution and package: `template-python` / `template_python` + in `pyproject.toml`, `src/`, tests, `Makefile`, `Dockerfile`, `compose.yml`, + workflows, `mkdocs.yml`, and docs. Run `uv lock`. +2. Replace the owner in `pyproject.toml` URLs, `.github/CODEOWNERS`, + `SECURITY.md`, badges, and `mkdocs.yml`. +3. Choose the settings prefix (`PROJECT_`) and update `.env.template` and the + configuration reference. +4. Replace the `hello` command and `core.greeting` with real behavior and tests; + keep `--version` and `config`. +5. Remove unused optional assets together with their dependencies, Make + targets, workflows, and links: the notebook group and `notebooks/`, MkDocs and + the Pages/SWA workflows, the Dev Container, or `compose.yml`. +6. In repository settings, enable private vulnerability reporting, Dependabot + alerts, code scanning, and branch protection requiring the `test`, + `security`, `codeql`, and `docker` checks. +7. Run `make ci-test ci-test-docs ci-test-security ci-test-docker` and tag the + first release. diff --git a/docs/development.ja.md b/docs/development.ja.md index 3797313..b6db61f 100644 --- a/docs/development.ja.md +++ b/docs/development.ja.md @@ -5,15 +5,14 @@ [エンジニアリングガイド](architecture/README.ja.md)を参照してください。 以下のコマンドはすべてリポジトリのルートで実行します。 -## 最初に一度だけ準備する +## 最初に一度だけ準備する { #set-up-once } | ツール | 必要になる場面 | | --- | --- | -| Python 3.12+ | パッケージとテスト。CI の全検証に合わせるなら 3.13 | -| [uv 0.12.19](https://docs.astral.sh/uv/getting-started/installation/) | ロック済み依存関係の管理とコマンド実行 | +| [uv 0.13.x](https://docs.astral.sh/uv/getting-started/installation/) | 常に必要。`pyproject.toml` で `>=0.13.1,<0.14` を要求 | +| Python 3.12〜3.15 | uv が必要に応じて導入。`.python-version` で 3.14 を選択 | | GNU Make | リポジトリの標準コマンド | -| [actionlint](https://github.com/rhysd/actionlint/blob/main/docs/install.md) | `make lint` と `make ci-test`。CI は 1.7.12 を使用 | -| Docker と起動済みの daemon | コンテナ関連のコマンドだけ | +| Docker と起動済みのデーモン | コンテナ検証、`secret-scan`、`security-scan` のみ | ```shell make install-deps-dev @@ -21,31 +20,32 @@ uv run --locked template-python --help make test ``` -`.venv` は uv が管理します。別の `pip install` でプロジェクトの依存関係を -導入したり、仮想環境を有効化したりする必要はありません。 -`make install-deps-dev` が表示する actionlint の案内は、Python テストの実行を -妨げません。 +`.venv` は `uv` が管理します。依存関係を別途 `pip install` でインストールしないで +ください。仮想環境の有効化も不要です。Ruff、mypy、actionlint、zizmor、pip-audit は +`uv.lock` からインストールされるため、グローバルなツールは不要です。 -Git hook を使う場合だけ、次を実行します。 +Git フックを一度インストールします。 ```shell make install-hooks make hooks-check ``` -Hook は YAML、空白、Ruff のルールを確認します。型検査、テスト、全品質ゲートの -代わりにはなりません。 +フックはファイルの整形確認、秘密鍵とシークレットの検出(gitleaks)、 +`uv lock --check`、Ruff、mypy、actionlint、zizmor を実行します。Python ツールは +`uv run --locked` 経由で動くため、フックと CI のバージョンは常に一致します。 +フックはテストを実行しないので、push 前に `make ci-test` を実行してください。 -## 日常の開発 +## 日々の開発 { #everyday-development } 1. [変更箇所の早見表](architecture/README.ja.md#where-to-change-code)で、 - 変更するモジュールとテストを特定します。 -2. 期待する出力と失敗時の振る舞いを、小さなテストで定義します。 -3. その契約を満たす最も単純な変更を実装します。 -4. 対象のテストを実行し、整形と変更範囲に合う検証を行います。 -5. 振る舞いが変わる場合は、対応する英語と日本語の説明を更新します。 + 対象モジュールと対応するテストを確認します。 +2. 期待する出力と失敗時の動作を、対象を絞ったテストで定義します。 +3. その契約を満たす最小限の変更を実装します。 +4. 対象テスト、整形、変更した領域の検証を順に実行します。 +5. 動作を変更した場合は、関連する英語と日本語のドキュメントを更新します。 -挨拶や CLI を変更する場合の例です。 +挨拶や CLI を変更する場合は、次のコマンドから始めます。 ```shell uv run --locked pytest --no-cov tests/test_core.py tests/test_cli.py @@ -53,120 +53,143 @@ make fix make ci-test ``` -`--no-cov` は対象を絞った反復作業だけに使います。一部のテストでパッケージ全体の -カバレッジを測ると、変更とは無関係な不足で失敗するためです。 -`make test` と `make ci-test` は全テストと branch coverage を実行し、 -**95% 以上**を要求します。一部のテストを通すために閾値を下げないでください。 +`--no-cov` は、対象を絞った確認のためだけに使います。一部のテストだけで +パッケージ全体のカバレッジ基準を測ると、誤った失敗になるためです。 +`make test` と `make ci-test` は全テストを分岐カバレッジ付きで実行し、 +**95%** 以上を要求します(テンプレート自体は 100%)。pytest は strict モードで動き、 +警告をエラーとして扱います。通すためだけに基準を下げたり、警告を広く無視したり +しないでください。 - +テストは一時的な作業ディレクトリで、`PROJECT_*` 変数を取り除いた状態で実行されます +(`tests/conftest.py` を参照)。手元のシェルや `.env` は結果に影響しません。 -### 変更内容に応じた検証 +### 変更内容ごとの検証 { #checks-by-change-type } -| 変更対象 | 作業中の確認 | 提出前の検証 | +| 変更した領域 | 素早い確認 | 提出前 | | --- | --- | --- | -| Python の処理・テスト | `--no-cov` 付きの対象テスト、`make fix`、`make type-check` | `make ci-test` | -| 依存関係・パッケージ情報 | `uv lock`、`make package-check` | `make ci-test`。影響する場合は docs・コンテナも検証 | -| Markdown・`mkdocs.yml` | `make docs-serve` | `make ci-test-docs` | -| GitHub Workflow | `actionlint` | `make ci-test` と、その Workflow が呼ぶ検証 | -| Dockerfile・Compose | `make docker-build docker-run` | `make ci-test-docker`。Compose 変更時は `docker compose up --build` も実行 | +| Python の動作やテスト | 対象テストを `--no-cov` 付きで実行、`make fix`、`make type-check` | `make ci-test` | +| 依存関係やパッケージ情報 | `uv lock`、`make package-check` | `make ci-test`、`make audit`、`make test-lowest` | +| Markdown や `mkdocs.yml` | `make docs-serve` | `make ci-test-docs` | +| GitHub ワークフローや Dependabot | `uv run --locked actionlint`、`uv run --locked zizmor --offline .` | `make ci-test` | +| Dockerfile や Compose | `make docker-build docker-run` | `make ci-test-docker`。Compose を変更した場合は `docker compose up --build` も実行 | -複数の領域を変更したら、該当する検証をすべて行います。ドキュメントだけの変更で -Python やコンテナの品質ゲートを実行する必要はありません。 +複数の領域にまたがる場合は、該当する検証をすべて実行します。 -新しい手順を覚える代わりに、既存コマンドを使います。 - -| コマンド | 実行すること | +| コマンド | 内容 | | --- | --- | -| `make help` | 利用できる Make target の一覧表示 | -| `make fix` | Ruff による自動修正と整形。自動変更の内容は確認する | -| `make lint` | mypy strict、Ruff、actionlint、オフラインの zizmor | -| `make test` | branch coverage 付きの pytest | -| `make package-check` | wheel と sdist をビルドし、wheel の CLI とモジュール起動を隔離環境で確認 | -| `make ci-test` | dev 依存関係の同期、整形確認、Lint、テスト、配布物検証 | - -CI は Python 3.13 で全品質ゲートと docs ビルド、3.12 と 3.14 で互換性テスト、 -別の job で hook の検証を行います。 - - - -## 設定リファレンス - -CLI は既定値で動作します。ローカル設定を変更する場合だけ、 +| `make help` | 利用できる Make ターゲットを表示 | +| `make fix` | Ruff の自動修正と整形を適用。変更内容は確認する | +| `make lint` | strict な mypy、Ruff(セキュリティルールを含む)、actionlint、zizmor を実行 | +| `make test` | 分岐カバレッジ付きで pytest を実行 | +| `make test-lowest` | Python 3.12 と、宣言した直接依存の下限バージョンでテスト | +| `make package-check` | wheel と sdist をビルドし、インストール後の CLI、モジュール実行、`--version` を確認 | +| `make ci-test` | 開発用依存関係を同期し、整形、Lint、テスト、パッケージ検証を実行 | +| `make audit` | ロック済みの全依存関係を pip-audit で監査(ハッシュ検証、pip 不使用) | +| `make secret-scan` | gitleaks で Git 履歴全体をスキャン(Docker) | +| `make security-scan` | Trivy で脆弱な依存関係、設定ミス、シークレットをスキャン(Docker) | +| `make ci-test-security` | `audit`、`secret-scan`、`security-scan` を実行 | + +CI は Python 3.14 で全検証とドキュメントのビルドを行い、3.12・3.13・3.15 の互換性 +テスト、下限依存のジョブ、フックを別ジョブで実行します。セキュリティスキャンは +プルリクエストと毎週実行されます。詳細は +[セキュリティとサプライチェーン](architecture/README.ja.md#security-and-supply-chain) +を参照してください。 + +## 設定 { #configuration } + +CLI は既定値だけで動作します。ローカルで値を変更する場合のみ、 `.env.template` を `.env` にコピーします。 ```shell cp .env.template .env ``` -| 環境変数 | 既定値 | 許容値・影響 | +| 変数 | 既定値 | 指定できる値と効果 | | --- | --- | --- | -| `PROJECT_NAME` | `template-python` | 起動時の debug ログに使う文字列。挨拶の出力は変わらない | +| `PROJECT_NAME` | `template-python` | デバッグ用の起動ログに表示する文字列。挨拶文は変わりません | | `PROJECT_LOG_LEVEL` | `INFO` | `DEBUG`、`INFO`、`WARNING`、`ERROR`、`CRITICAL` | +| `PROJECT_LOG_FORMAT` | `text` | 人が読む `text`、または 1 行 1 JSON オブジェクトの `json`(ログ収集基盤やコンテナ向け) | -優先順位は、環境変数、`.env`、既定値の順です。変数名の大文字・小文字は区別 -しませんが、ログレベルの**値**は上記の大文字表記にします。 -`.env` は作業ディレクトリから読み、未知の項目は拒否します。別のアプリケーションの -`.env` を流用しないでください。ローカル設定や秘密情報はコミットしません。 +優先順位は、環境変数、`.env`、既定値の順です。 +変数名の大文字と小文字は区別しませんが、**値**は上記の選択肢と完全に一致させてください。 +`.env` は作業ディレクトリから読み込まれ、未知の `PROJECT_*` 項目はエラーになります。 +別のアプリケーションの `.env` は再利用しないでください。ローカル設定やシークレットは +バージョン管理に含めないでください。 ```shell PROJECT_LOG_LEVEL=DEBUG uv run --locked template-python hello --name Ada -uv run --locked template-python --verbose hello --name Ada +PROJECT_LOG_FORMAT=json uv run --locked template-python --verbose hello +uv run --locked template-python config ``` -`--verbose` はグローバルオプションなので、`hello` の前に置きます。 -設定を検証した後で debug ログを有効にするため、不正な設定を回避する用途には -使えません。正常な挨拶は stdout、ログと検証エラーは stderr に出力します。 -空白だけの `--name` と不正な設定は、終了コード **2** で失敗します。 +`config` は有効な設定を JSON で出力します。認証情報は `pydantic.SecretStr` で宣言 +してください。値は `**********` と表示されます。 -## 依存関係 - -- `pyproject.toml` の依存宣言を編集し、`uv lock` を実行して両方のファイルを - コミットします。解決済みの lock エントリを手で編集しません。 -- `make update` は全体の更新を意図した場合だけ使います。既存バージョンの同期では - なく、`uv lock --upgrade` を実行するコマンドです。 -- 実行時の依存関係は `project.dependencies`、開発・テスト・docs・Notebook の - ツールは既存の各グループへ追加します。 -- 既存コマンドは必要な依存グループを選んで同期します。docs と Notebook は - `--no-dev` を使い、その後の dev コマンドで必要な dev グループが再び同期されます。 - - - -## ドキュメントの開発 +| グローバルオプション | 効果 | +| --- | --- | +| `--verbose`、`-v` | 設定の検証後にデバッグログを選択。不正な設定を有効にはしません | +| `--version`、`-V` | `template-python ` を表示して終了。設定が不正でも動作します | +| `--help` | ヘルプを表示。コマンドなしで実行した場合もヘルプを表示(終了コード 2) | + +グローバルオプションはコマンドより前に指定します(`template-python --verbose hello`)。 +コマンドの出力は標準出力へ、ログと検証エラーは標準エラー出力へ書き込まれます。 +空白だけの `--name` や不正な設定は、終了コード **2** で終了します。 + +## 依存関係 { #dependencies } + +- 依存関係の宣言は `pyproject.toml` で変更し、`uv lock` を実行してから、 + マニフェストと `uv.lock` の両方をコミットします。ロックファイルの解決結果は + 手作業で編集しないでください。 +- 下限バージョンを正しく保ちます。`make test-lowest` は直接依存の下限バージョンを + インストールし、成功する必要があります。 +- `make update` は、意図的に依存関係を広く更新する場合だけ使います。 + 現在のバージョンを同期するのではなく、`uv lock --upgrade` を実行します。 + 実行後は `make audit` を実行してください。 +- 実行時の依存関係は `project.dependencies` に置き、開発、テスト、ドキュメント、 + Notebook 用のツールは既存のグループに置きます。 +- Dependabot は Python パッケージ、GitHub Actions、pre-commit フック、Docker の + ベースイメージ、Dev Container の feature について、7 日間のクールダウン後に + グループ化した更新を毎週作成します。`Makefile` のツールイメージと + Dev Container の uv バージョンは手動で固定しています。uv を上げる場合は + `[tool.uv] required-version` と合わせて更新してください。 + +## ドキュメント { #documentation } ```shell make docs-serve ``` -MkDocs が表示する URL(通常は `http://127.0.0.1:8000`)を開きます。 -`Ctrl+C` でプレビューを終了し、次のコマンドで検証します。 +MkDocs が表示するアドレス(通常は `http://127.0.0.1:8000`)を開きます。 +`Ctrl+C` でプレビューを止めてから、次のコマンドで検証します。 ```shell make ci-test-docs ``` -英語と日本語の出力は、Git 管理対象外の `site/` に生成されます。 -`page.md` と `page.ja.md` は一緒に編集してください。ページを追加するときは -`mkdocs.yml` のナビゲーションと日本語のラベルも追加します。 -ドキュメント同士は相対リンクにします。README は導入、このページは手順、 -エンジニアリングガイドは設計理由を担当します。 - - +ビルドは strict モードで実行され、英語と日本語のページを Git の管理対象外である +`site/` に出力します。`page.md` と `page.ja.md` は一緒に編集します。他のページから +リンクされる見出しには `## 見出し { #heading }` のように明示的な ID を付け、 +両言語でアンカーを共有します。新しいページは日本語のナビゲーション名も含めて +`mkdocs.yml` に追加し、ドキュメント間のリンクには相対パスを使います。導入手順は +README、作業手順はこのガイド、設計理由はエンジニアリングガイドに置きます。 -## トラブルシューティング +## トラブルシューティング { #troubleshooting } -検証を無効にしたり、複数のツールを一度に変更したりせず、最初に失敗した -コマンドから原因を確認します。 +検証を無効化したり、複数のツールを同時に変更したりせず、最初に失敗したコマンドから +修正します。 -| 症状 | 確認すること・次の行動 | +| 症状 | 確認すること / 次の対応 | | --- | --- | -| `uv: command not found` | 指定バージョンの uv を導入し、`PATH` と `uv --version` を確認する | -| Python のバージョンが合わない | 例として `uv sync --locked --group dev --python 3.13` で対応 interpreter を選ぶ | -| Lint・CI で `actionlint: command not found` | actionlint を導入して `actionlint --version` を確認する。対象の確認には `make test` を使える | -| lock の更新が必要と表示される | 意図した依存変更なら `uv lock` で両方を更新する。それ以外は `--locked` を外さず宣言と lock のずれを調べる | -| `Invalid configuration`・終了コード 2 | `PROJECT_*` と作業ディレクトリの `.env` を確認し、大文字の許容ログレベルを使う | -| 対象テストだけの実行でカバレッジ不足 | 対象を絞る間は `--no-cov`、実際の品質ゲートには `make test` を使う | -| Docker daemon に接続できない | Docker を起動して再実行する。Python だけの作業ならコンテナ環境は不要 | -| ページが表示されない・リンクが切れている | `mkdocs.yml`、英日のファイル、リンク先を確認し、両言語を再ビルドする | - -相談するときは、実行コマンド、最初のエラー、OS、Python・uv のバージョン、 -関連する変更を添えます。秘密情報や非公開の設定は伏せてください。 +| `uv: command not found` | uv 0.13.x をインストールし、`PATH` に含まれることを `uv --version` で確認 | +| `Required uv version ... does not match` | `[tool.uv] required-version` の範囲に uv を合わせる(例: `uv self update`) | +| Python のバージョンが合わない | サポート対象のインタープリターを指定。例: `uv sync --locked --group dev --python 3.13` | +| ロックファイルの更新が必要と表示される | 意図した依存関係の変更なら `uv lock` を実行して両方を確認。そうでなければ `--locked` を外さず差異の原因を調べる | +| `make audit` が脆弱性を報告する | パッケージを更新(`uv lock --upgrade-package `)して再実行。ID を無視する場合は理由を記録する | +| `make test-lowest` が失敗する | `pyproject.toml` の下限を動作する最初のバージョンへ上げてから `uv lock` | +| `Invalid configuration` / 終了コード 2 | `PROJECT_*` 変数と作業ディレクトリの `.env` を確認し、指定できる値を使う | +| 一部のテストでカバレッジ基準を満たせない | その場合は `--no-cov` を使い、正式なカバレッジ判定は `make test` で行う | +| Docker デーモンに接続できない | Docker を起動してから再実行。コンテナとスキャンのターゲット以外では不要 | +| 新しいページが表示されない、またはリンク切れになる | `mkdocs.yml`、両言語のファイル、見出し ID、リンク先を確認して再ビルド | + +問い合わせる際は、実行したコマンド、最初のエラー、OS、Python と uv のバージョン、 +関連する変更を記載してください。シークレットや非公開の設定は伏せてください。 diff --git a/docs/development.md b/docs/development.md index 85422d6..515f5d5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -5,15 +5,14 @@ For module boundaries and design decisions, use the [engineering guide](architecture/README.md). All commands below run from the repository root. -## Set up once +## Set up once { #set-up-once } | Tool | When it is needed | | --- | --- | -| Python 3.12+ | Package and tests; prefer 3.13 to match the full CI job | -| [uv 0.12.19](https://docs.astral.sh/uv/getting-started/installation/) | Locked dependencies and command execution | +| [uv 0.13.x](https://docs.astral.sh/uv/getting-started/installation/) | Always; `pyproject.toml` requires `>=0.13.1,<0.14` | +| Python 3.12–3.15 | Installed by uv on demand; `.python-version` selects 3.14 | | GNU Make | The repository's standard commands | -| [actionlint](https://github.com/rhysd/actionlint/blob/main/docs/install.md) | `make lint` and `make ci-test`; CI uses 1.7.12 | -| Docker with a running daemon | Container commands only | +| Docker with a running daemon | Container checks, `secret-scan`, and `security-scan` only | ```shell make install-deps-dev @@ -22,20 +21,22 @@ make test ``` `uv` manages `.venv`; do not install project dependencies with a separate -`pip install` command. No activation is needed. The actionlint reminder printed -by `make install-deps-dev` does not prevent Python tests from running. +`pip install` command. No activation is needed. Ruff, mypy, actionlint, zizmor, +and pip-audit are installed from `uv.lock`, so no global tools are required. -Optional Git hooks: +Install the Git hooks once: ```shell make install-hooks make hooks-check ``` -Hooks check YAML, whitespace, and Ruff rules. They do not replace type checking, -tests, or the full quality gate. +Hooks run file hygiene checks, private-key and secret detection (gitleaks), +`uv lock --check`, Ruff, mypy, actionlint, and zizmor. Python tools run through +`uv run --locked`, so hooks and CI use identical versions. Hooks do not run the +tests; use `make ci-test` before pushing. -## Everyday development +## Everyday development { #everyday-development } 1. Find the module and its test using the [change map](architecture/README.md#where-to-change-code). @@ -54,37 +55,46 @@ make ci-test `--no-cov` is only for the focused feedback loop: measuring a subset against the whole-package coverage threshold would give misleading failures. `make test` -and `make ci-test` run all tests with branch coverage and enforce **95%** minimum -coverage. Do not lower the threshold to make a focused run pass. +and `make ci-test` run all tests with branch coverage and enforce at least +**95%** (the template itself is at 100%). pytest runs in strict mode and turns +warnings into errors. Do not lower the threshold or ignore warnings broadly to +make a run pass. -### Checks by change type +Tests run in an isolated temporary working directory with `PROJECT_*` variables +removed (see `tests/conftest.py`), so your shell and `.env` do not affect results. + +### Checks by change type { #checks-by-change-type } | Changed area | Focused feedback | Before submitting | | --- | --- | --- | | Python behavior or tests | Selected tests with `--no-cov`, `make fix`, `make type-check` | `make ci-test` | -| Dependencies or package metadata | `uv lock`, `make package-check` | `make ci-test`; docs/container checks if affected | +| Dependencies or package metadata | `uv lock`, `make package-check` | `make ci-test`, `make audit`, `make test-lowest` | | Markdown or `mkdocs.yml` | `make docs-serve` | `make ci-test-docs` | -| GitHub workflows | `actionlint` | `make ci-test` and the checks invoked by the changed workflow | +| GitHub workflows or Dependabot | `uv run --locked actionlint`, `uv run --locked zizmor --offline .` | `make ci-test` | | Dockerfile or Compose | `make docker-build docker-run` | `make ci-test-docker`; `docker compose up --build` if Compose changed | -Run the union of checks when a change spans areas. Documentation-only changes -do not need the Python or container gate. - -Useful commands, without a new workflow to memorize: +Run the union of checks when a change spans areas. | Command | What it does | | --- | --- | | `make help` | Lists supported Make targets | | `make fix` | Applies Ruff fixes and formatting; review any automatic changes | -| `make lint` | Runs strict mypy, Ruff, actionlint, and offline zizmor | +| `make lint` | Runs strict mypy, Ruff (including security rules), actionlint, and zizmor | | `make test` | Runs pytest with branch coverage | -| `make package-check` | Builds wheel and sdist, then tests the wheel's installed CLI and module entry point in isolation | +| `make test-lowest` | Runs tests on Python 3.12 with the lowest declared direct dependency versions | +| `make package-check` | Builds wheel and sdist, then tests the installed CLI, module entry point, and `--version` | | `make ci-test` | Syncs dev dependencies and runs formatting, lint, tests, and package checks | +| `make audit` | Audits every locked dependency with pip-audit (hash-verified, no pip) | +| `make secret-scan` | Scans the full Git history with gitleaks (Docker) | +| `make security-scan` | Scans files with Trivy for vulnerable dependencies, misconfigurations, and secrets (Docker) | +| `make ci-test-security` | Runs `audit`, `secret-scan`, and `security-scan` | -CI runs the full gate and docs build on Python 3.13, compatibility tests on -3.12 and 3.14, and hooks in a separate job. +CI runs the full gate and docs build on Python 3.14, compatibility tests on +3.12, 3.13, and 3.15, the lowest-dependency job, and hooks in separate jobs. +Security scans run on pull requests and weekly; see +[security and supply chain](architecture/README.md#security-and-supply-chain). -## Configuration +## Configuration { #configuration } The CLI works with defaults. Copy `.env.template` to `.env` only if local overrides are needed: @@ -97,35 +107,51 @@ cp .env.template .env | --- | --- | --- | | `PROJECT_NAME` | `template-python` | String used in the debug startup log; does not change the greeting | | `PROJECT_LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` | +| `PROJECT_LOG_FORMAT` | `text` | `text` for humans, `json` for one JSON object per line (log collectors, containers) | Environment variables override `.env`, which overrides defaults. -Names are case-insensitive; log-level **values** must use the uppercase choices -above. `.env` is read from the working directory and unknown entries are +Names are case-insensitive; **values** must use the exact choices above. `.env` +is read from the working directory and unknown `PROJECT_*` entries are rejected, so do not reuse an unrelated application's `.env`. Keep local settings and secrets out of version control. ```shell PROJECT_LOG_LEVEL=DEBUG uv run --locked template-python hello --name Ada -uv run --locked template-python --verbose hello --name Ada +PROJECT_LOG_FORMAT=json uv run --locked template-python --verbose hello +uv run --locked template-python config ``` -`--verbose` is a global option: place it before `hello`. It selects debug logging -after settings validation; it cannot make an invalid setting valid. -Successful greeting output goes to stdout; logs and validation errors go to -stderr. Blank `--name` values and invalid settings exit with code **2**. +`config` prints the effective settings as JSON. Declare credentials as +`pydantic.SecretStr`; they are printed as `**********`. + +| Global option | Effect | +| --- | --- | +| `--verbose`, `-v` | Selects debug logging after settings validation; it cannot make an invalid setting valid | +| `--version`, `-V` | Prints `template-python ` and exits, even if settings are invalid | +| `--help` | Shows help; running without a command also shows help (exit code 2) | + +Place global options before the command (`template-python --verbose hello`). +Command output goes to stdout; logs and validation errors go to stderr. Blank +`--name` values and invalid settings exit with code **2**. -## Dependencies +## Dependencies { #dependencies } - Edit dependency declarations in `pyproject.toml`, run `uv lock`, and commit both the manifest and `uv.lock`. Do not hand-edit resolved lock entries. +- Keep lower bounds honest: `make test-lowest` installs the lowest allowed direct + dependency versions and must pass. - Use `make update` only for an intentional broad upgrade: it runs - `uv lock --upgrade`, not just a sync of the current versions. + `uv lock --upgrade`, not just a sync of the current versions. Run `make audit` + afterwards. - Keep runtime dependencies in `project.dependencies`; keep development, test, docs, and notebook tools in their existing groups. -- Existing commands select and sync their dependency groups. Docs and notebook - commands use `--no-dev`; a later dev command restores the dev group as needed. +- Dependabot opens grouped weekly updates after a 7-day cooldown for Python + packages, GitHub Actions, pre-commit hooks, Docker base images, and Dev + Container features. Tool images in the `Makefile` and the uv version in the Dev + Container are pinned by hand; update them together with `[tool.uv] + required-version` when raising uv. -## Documentation +## Documentation { #documentation } ```shell make docs-serve @@ -138,27 +164,31 @@ preview with `Ctrl+C`, then validate with: make ci-test-docs ``` -The build writes English and Japanese pages to the ignored `site/` directory. -Edit `page.md` and `page.ja.md` together. Add new pages to `mkdocs.yml`, including -Japanese navigation labels. Use relative links between documentation pages. -Keep onboarding in the README, procedures here, and design rationale in the -engineering guide. +The build runs in strict mode and writes English and Japanese pages to the +ignored `site/` directory. Edit `page.md` and `page.ja.md` together. Give +headings that are linked from other pages an explicit ID such as +`## Heading { #heading }` so both languages share anchors. Add new pages to +`mkdocs.yml`, including Japanese navigation labels. Use relative links between +documentation pages. Keep onboarding in the README, procedures here, and design +rationale in the engineering guide. -## Troubleshooting +## Troubleshooting { #troubleshooting } Fix the first failing command rather than disabling checks or changing several tools at once. | Symptom | What to check / next action | | --- | --- | -| `uv: command not found` | Install the pinned uv version and ensure its directory is on `PATH`; confirm with `uv --version` | +| `uv: command not found` | Install uv 0.13.x and ensure its directory is on `PATH`; confirm with `uv --version` | +| `Required uv version ... does not match` | Upgrade or downgrade uv into the range in `[tool.uv] required-version` (for example `uv self update`) | | Python version is incompatible | Select a supported interpreter, for example `uv sync --locked --group dev --python 3.13` | -| `actionlint: command not found` during lint/CI | Install actionlint and confirm `actionlint --version`; `make test` remains available for focused work | | Lock file needs updating | If dependencies intentionally changed, run `uv lock` and review both files; otherwise investigate manifest/lock drift rather than removing `--locked` | -| `Invalid configuration` / exit code 2 | Check `PROJECT_*` variables and the working directory's `.env`; use an accepted uppercase log level | +| `make audit` reports a vulnerability | Upgrade the package (`uv lock --upgrade-package `) and rerun; only ignore an ID with a documented reason | +| `make test-lowest` fails | Raise the lower bound in `pyproject.toml` to the first version that works, then `uv lock` | +| `Invalid configuration` / exit code 2 | Check `PROJECT_*` variables and the working directory's `.env`; use an accepted value | | A selected test run fails coverage | Use `--no-cov` for the subset, then run `make test` for the actual coverage gate | -| Docker cannot connect to the daemon | Start Docker before retrying; container tooling is not needed for Python-only work | -| A new page is absent or a link is broken | Check `mkdocs.yml`, both language files, and the link target, then rebuild both languages | +| Docker cannot connect to the daemon | Start Docker before retrying; it is only needed for container and scan targets | +| A new page is absent or a link is broken | Check `mkdocs.yml`, both language files, heading IDs, and the link target, then rebuild | When requesting help, include the exact command, first failure, OS, Python/uv versions, and relevant change. Redact secrets and private configuration. diff --git a/docs/index.ja.md b/docs/index.ja.md index 85e1e35..85f661f 100644 --- a/docs/index.ja.md +++ b/docs/index.ja.md @@ -1,13 +1,14 @@ # template-python -Python 3.12+ 向けの、型付きパッケージ兼 CLI テンプレートです。まずサンプルを -動かし、その後は目的に合うガイドだけを読んでください。コンテナ、Notebook、 -デプロイは任意であり、開発を始めるための前提ではありません。 +Python 3.12+ 向けの、型付き CLI テンプレートです。本番運用に耐えるツールチェーンを +備えています。まずサンプルを動かし、その後は目的に合うガイドだけを読んでください。 +コンテナ、Notebook、デプロイは任意であり、開発を始めるための前提ではありません。 ## まず動かす -[uv 0.12.19](https://docs.astral.sh/uv/getting-started/installation/)、 -Python 3.12+、GNU Make を用意し、リポジトリのルートで実行します。 +[uv 0.13.x](https://docs.astral.sh/uv/getting-started/installation/) と GNU Make を +用意します。Python 3.14 は `.python-version` に従って uv が導入します。 +リポジトリのルートで実行します。 ```shell make install-deps-dev @@ -21,8 +22,9 @@ CLI の出力は次のとおりです。 Hello, Ada! ``` -`.env` の作成や仮想環境の有効化は不要です。CI の全検証は Python 3.13 で行い、 -3.12 と 3.14 でも互換性テストを実行します。 +`.env` の作成や仮想環境の有効化は不要です。CI の全検証は Python 3.14 で行い、 +3.12・3.13・3.15 でも互換性テストを実行します。宣言した依存関係の下限バージョンも +3.12 でテストします。 ## 目的から探す @@ -31,6 +33,7 @@ Hello, Ada! | 開発環境を用意し、コードを変更・検証する | [開発ガイド](development.ja.md) | | 変更するモジュールや設計方針を判断する | [アーキテクチャとエンジニアリング](architecture/README.ja.md) | | 環境変数を設定し、CLI のエラーを理解する | [設定リファレンス](development.ja.md#configuration) | +| スキャナー、CI、リリースの仕組みを理解する | [セキュリティとサプライチェーン](architecture/README.ja.md#security-and-supply-chain) | | コマンドの失敗から復旧する | [トラブルシューティング](development.ja.md#troubleshooting) | | テンプレートを改名し、不要な機能を外す | [テンプレートの導入](architecture/README.ja.md#adopting-the-template) | | ドキュメントをプレビュー・検証する | [ドキュメントの開発](development.ja.md#documentation) | @@ -39,7 +42,8 @@ Hello, Ada! - 再利用する処理はコアへ、設定・ログ・表示・終了コードは CLI へ置きます。 - コマンドの正本は `Makefile` です。ツールのオプションを覚える代わりに - `make help` を使います。 -- 依存関係の宣言は `pyproject.toml`、解決済みバージョンは `uv.lock` が管理します。 + `make help` を使います。CI も同じターゲットを呼びます。 +- 依存関係とツール設定の宣言は `pyproject.toml`、解決済みバージョンは `uv.lock` が + 管理します。Git フックが使うリンターも `uv.lock` に含まれます。 - 英語と日本語のページを揃えます。詳細な手順を README に重複させず、 該当するガイドへリンクします。 diff --git a/docs/index.md b/docs/index.md index c391fd2..eb02267 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,13 +1,14 @@ # template-python -A typed Python 3.12+ package and CLI template. Start with the runnable example, -then read only the guide needed for your task. Containers, notebooks, and -deployment are optional, not prerequisites. +A typed Python 3.12+ CLI template with a production-grade toolchain. Start with +the runnable example, then read only the guide needed for your task. Containers, +notebooks, and deployment are optional, not prerequisites. ## Run the example -Install [uv 0.12.19](https://docs.astral.sh/uv/getting-started/installation/), -Python 3.12+, and GNU Make. Run these commands from the repository root: +Install [uv 0.13.x](https://docs.astral.sh/uv/getting-started/installation/) +and GNU Make. uv installs Python 3.14 from `.python-version`. Run these commands +from the repository root: ```shell make install-deps-dev @@ -21,8 +22,9 @@ The CLI prints: Hello, Ada! ``` -No `.env` file or virtual environment activation is required. Python 3.13 is the -full CI baseline; 3.12 and 3.14 also have compatibility tests. +No `.env` file or virtual environment activation is required. Python 3.14 is the +full CI baseline; 3.12, 3.13, and 3.15 also have compatibility tests, and the +lowest declared dependency versions are tested on 3.12. ## Find the next step @@ -31,6 +33,7 @@ full CI baseline; 3.12 and 3.14 also have compatibility tests. | Set up development, change code, and select checks | [Development](development.md) | | Find the right module or make a design decision | [Architecture and engineering](architecture/README.md) | | Set environment variables or understand CLI errors | [Configuration](development.md#configuration) | +| Understand scanners, CI, and releases | [Security and supply chain](architecture/README.md#security-and-supply-chain) | | Recover from a failed command | [Troubleshooting](development.md#troubleshooting) | | Rename the template or remove unused assets | [Adopting the template](architecture/README.md#adopting-the-template) | | Preview and validate these pages | [Documentation workflow](development.md#documentation) | @@ -40,7 +43,8 @@ full CI baseline; 3.12 and 3.14 also have compatibility tests. - Reusable behavior belongs in the core; the CLI handles settings, logging, output, and exit codes. - `Makefile` is the command reference; use `make help` rather than memorizing - tool flags. -- `pyproject.toml` declares dependencies; `uv.lock` records resolved versions. + tool flags. CI calls the same targets. +- `pyproject.toml` declares dependencies and tool settings; `uv.lock` records + resolved versions, including the linters used by Git hooks. - Keep English and Japanese pages aligned. Do not duplicate detailed procedures in the README; link to the relevant guide instead. diff --git a/mkdocs.yml b/mkdocs.yml index 682ca33..9e62ba1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -27,6 +27,14 @@ theme: name: Switch to system preference icon: repo: fontawesome/brands/github +markdown_extensions: + - attr_list + - admonition + - tables + - toc: + permalink: true +validation: + anchors: warn plugins: - i18n: docs_structure: suffix diff --git a/pyproject.toml b/pyproject.toml index f1acf21..658f485 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,23 +1,27 @@ [project] name = "template-python" version = "0.0.13" -description = "A minimal, typed Python package and CLI template" +description = "A minimal, typed Python CLI template with a production-grade toolchain" readme = "README.md" requires-python = ">=3.12" license = "MIT" license-files = ["LICENSE"] -keywords = ["python", "template", "typer", "uv"] +authors = [{ name = "ks6088ts" }] +keywords = ["cli", "python", "template", "typer", "uv"] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", - "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Typing :: Typed", ] dependencies = [ + "pydantic>=2.12,<3", "pydantic-settings>=2.15,<3", "typer>=0.27,<1", ] @@ -42,7 +46,9 @@ test = [ ] dev = [ { include-group = "test" }, + "actionlint-py>=1.7.12,<2", "mypy>=2.4,<3", + "pip-audit>=2.10,<3", "prek>=0.5.4,<0.6", "ruff>=0.17.1,<0.18", "zizmor>=1.30.1,<2", @@ -55,27 +61,75 @@ notebook = [ requires = ["uv_build>=0.13,<0.14"] build-backend = "uv_build" +[tool.uv] +required-version = ">=0.13.1,<0.14" + [tool.ruff] line-length = 120 target-version = "py312" [tool.ruff.lint] -select = ["B", "E", "F", "I", "PT", "RUF", "SIM", "UP"] +select = [ + "A", # flake8-builtins + "ARG", # flake8-unused-arguments + "B", # flake8-bugbear + "BLE", # flake8-blind-except + "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez + "E", # pycodestyle errors + "ERA", # eradicate (commented-out code) + "F", # pyflakes + "FBT", # flake8-boolean-trap + "FURB", # refurb + "G", # flake8-logging-format + "I", # isort + "LOG", # flake8-logging + "N", # pep8-naming + "PERF", # perflint + "PIE", # flake8-pie + "PL", # pylint + "PT", # flake8-pytest-style + "PTH", # flake8-use-pathlib + "RET", # flake8-return + "RUF", # ruff-specific + "S", # flake8-bandit (security) + "SIM", # flake8-simplify + "T20", # flake8-print + "TRY", # tryceratops + "UP", # pyupgrade + "W", # pycodestyle warnings +] + +[tool.ruff.lint.per-file-ignores] +# Typer declares boolean flags as keyword parameters with defaults. +"src/template_python/cli.py" = ["FBT001", "FBT002"] +"tests/**" = ["PLR2004", "S101"] -[tool.pytest.ini_options] -addopts = "-ra --cov=src/template_python" +[tool.pytest] +minversion = "9.0" +addopts = ["-ra", "--cov", "--cov-report=term-missing"] +strict = true testpaths = ["tests"] +filterwarnings = ["error"] [tool.coverage.run] branch = true +source_pkgs = ["template_python"] [tool.coverage.report] fail_under = 95 show_missing = true +partial_also = ['if __name__ == "__main__":'] [tool.mypy] files = ["src", "tests"] python_version = "3.12" strict = true -show_error_codes = true +warn_unreachable = true +enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] plugins = ["pydantic.mypy"] + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true diff --git a/src/template_python/__init__.py b/src/template_python/__init__.py index 8e0f674..c7ce31b 100644 --- a/src/template_python/__init__.py +++ b/src/template_python/__init__.py @@ -1,5 +1,12 @@ """Reusable building blocks for the template project.""" +from importlib.metadata import PackageNotFoundError, version + from template_python.core import greeting -__all__ = ["greeting"] +try: + __version__ = version("template-python") +except PackageNotFoundError: # pragma: no cover - only for an uninstalled source tree + __version__ = "0.0.0+unknown" + +__all__ = ["__version__", "greeting"] diff --git a/src/template_python/__main__.py b/src/template_python/__main__.py index 5d5bc5d..6dc25e0 100644 --- a/src/template_python/__main__.py +++ b/src/template_python/__main__.py @@ -2,4 +2,5 @@ from template_python.cli import app -app() +if __name__ == "__main__": + app(prog_name="template-python") diff --git a/src/template_python/cli.py b/src/template_python/cli.py index 2aaafe2..f4ea853 100644 --- a/src/template_python/cli.py +++ b/src/template_python/cli.py @@ -6,34 +6,54 @@ import typer from pydantic import ValidationError +from template_python import __version__ from template_python.core import greeting -from template_python.logging import configure_logging -from template_python.settings import get_project_settings +from template_python.log import configure_logging +from template_python.settings import ProjectSettings, get_project_settings app = typer.Typer( add_completion=False, help="A minimal, typed Python project template.", no_args_is_help=True, + pretty_exceptions_show_locals=False, ) logger = logging.getLogger(__name__) +def _print_version(value: bool) -> None: + if value: + typer.echo(f"template-python {__version__}") + raise typer.Exit + + @app.callback() def main( + ctx: typer.Context, verbose: Annotated[ bool, typer.Option("--verbose", "-v", help="Enable debug logging."), ] = False, + _version: Annotated[ + bool, + typer.Option( + "--version", + "-V", + callback=_print_version, + is_eager=True, + help="Show the version and exit.", + ), + ] = False, ) -> None: - """Configure the application before running a command.""" + """Load settings and configure logging before running a command.""" try: settings = get_project_settings() except ValidationError as exc: typer.echo(f"Invalid configuration: {exc}", err=True) raise typer.Exit(code=2) from exc - configure_logging("DEBUG" if verbose else settings.log_level) + configure_logging("DEBUG" if verbose else settings.log_level, settings.log_format) logger.debug("Loaded settings for %s", settings.name) + ctx.obj = settings @app.command() @@ -49,3 +69,10 @@ def hello( except ValueError as exc: raise typer.BadParameter(str(exc), param_hint="--name") from exc typer.echo(message) + + +@app.command("config") +def show_config(ctx: typer.Context) -> None: + """Print the effective configuration as JSON; secret values are masked.""" + settings = ctx.ensure_object(ProjectSettings) + typer.echo(settings.model_dump_json(indent=2)) diff --git a/src/template_python/log.py b/src/template_python/log.py new file mode 100644 index 0000000..7be6524 --- /dev/null +++ b/src/template_python/log.py @@ -0,0 +1,41 @@ +"""Process logging configuration for the application boundary.""" + +import json +import logging +import sys +from datetime import UTC, datetime +from typing import override + +TEXT_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" +LOG_FORMATS = frozenset({"text", "json"}) + + +class JsonFormatter(logging.Formatter): + """Render each record as a single-line JSON object for log collectors.""" + + @override + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, str] = { + "timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + if record.exc_info: + payload["exception"] = self.formatException(record.exc_info) + return json.dumps(payload, ensure_ascii=False) + + +def configure_logging(level: str, log_format: str = "text") -> None: + """Configure root logging on stderr; call once at the application boundary.""" + numeric_level = logging.getLevelNamesMapping().get(level.upper()) + if numeric_level is None: + msg = f"unsupported log level: {level}" + raise ValueError(msg) + if log_format not in LOG_FORMATS: + msg = f"unsupported log format: {log_format}" + raise ValueError(msg) + + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(JsonFormatter() if log_format == "json" else logging.Formatter(TEXT_FORMAT)) + logging.basicConfig(level=numeric_level, handlers=[handler], force=True) diff --git a/src/template_python/logging.py b/src/template_python/logging.py deleted file mode 100644 index 870f87b..0000000 --- a/src/template_python/logging.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Application logging configuration.""" - -import logging - -LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" - - -def configure_logging(level: str) -> None: - """Configure process logging at the application boundary.""" - numeric_level = logging.getLevelNamesMapping().get(level.upper()) - if numeric_level is None: - msg = f"unsupported log level: {level}" - raise ValueError(msg) - logging.basicConfig(level=numeric_level, format=LOG_FORMAT, force=True) diff --git a/src/template_python/settings.py b/src/template_python/settings.py index 1084bde..d77e142 100644 --- a/src/template_python/settings.py +++ b/src/template_python/settings.py @@ -5,13 +5,19 @@ from pydantic_settings import BaseSettings, SettingsConfigDict LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] +LogFormat = Literal["text", "json"] class ProjectSettings(BaseSettings): - """Settings loaded from ``PROJECT_*`` environment variables.""" + """Settings loaded from ``PROJECT_*`` environment variables and an optional ``.env`` file. + + Declare credentials as ``pydantic.SecretStr`` so they are masked in ``repr`` and in + the ``config`` command output. + """ name: str = "template-python" log_level: LogLevel = "INFO" + log_format: LogFormat = "text" model_config = SettingsConfigDict( env_file=".env", diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a1dd759 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +import logging +import os +from collections.abc import Iterator +from pathlib import Path + +import pytest + + +@pytest.fixture(autouse=True) +def isolated_environment(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[None]: + """Keep tests independent of the developer's shell, ``.env`` file, and logging state.""" + for key in os.environ: + if key.upper().startswith("PROJECT_"): + monkeypatch.delenv(key) + monkeypatch.chdir(tmp_path) + + root = logging.getLogger() + handlers, level = root.handlers[:], root.level + yield + root.handlers[:] = handlers + root.setLevel(level) diff --git a/tests/test_cli.py b/tests/test_cli.py index 38e2faa..3ca717b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,7 +1,15 @@ +import json +import runpy +import sys +from pathlib import Path + import pytest +from pydantic import SecretStr from typer.testing import CliRunner +from template_python import __version__, cli from template_python.cli import app +from template_python.settings import ProjectSettings runner = CliRunner() @@ -13,6 +21,29 @@ def test_cli_help() -> None: assert "A minimal, typed Python project template." in result.stdout +def test_cli_without_command_shows_help() -> None: + result = runner.invoke(app, []) + + assert result.exit_code == 2 + assert "Usage:" in result.stdout + + +@pytest.mark.parametrize("flag", ["--version", "-V"]) +def test_cli_version(flag: str) -> None: + result = runner.invoke(app, [flag]) + + assert result.exit_code == 0 + assert result.stdout == f"template-python {__version__}\n" + + +def test_cli_version_ignores_invalid_settings(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PROJECT_LOG_LEVEL", "VERBOSE") + + result = runner.invoke(app, ["--version"]) + + assert result.exit_code == 0 + + def test_cli_greets_name() -> None: result = runner.invoke(app, ["hello", "--name", "Ada"]) @@ -25,6 +56,18 @@ def test_cli_verbose_logging() -> None: assert result.exit_code == 0 assert "Loaded settings for template-python" in result.stderr + assert result.stdout == "Hello, World!\n" + + +def test_cli_json_logging(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PROJECT_LOG_FORMAT", "json") + + result = runner.invoke(app, ["--verbose", "hello"]) + + assert result.exit_code == 0 + record = json.loads(result.stderr.splitlines()[0]) + assert record["level"] == "DEBUG" + assert record["message"] == "Loaded settings for template-python" def test_cli_rejects_blank_name() -> None: @@ -41,3 +84,44 @@ def test_cli_reports_invalid_settings(monkeypatch: pytest.MonkeyPatch) -> None: assert result.exit_code == 2 assert "Invalid configuration" in result.stderr + + +def test_cli_reads_dotenv_from_working_directory(tmp_path: Path) -> None: + (tmp_path / ".env").write_text("PROJECT_NAME=from-dotenv\n", encoding="utf-8") + + result = runner.invoke(app, ["config"]) + + assert result.exit_code == 0 + assert json.loads(result.stdout)["name"] == "from-dotenv" + + +def test_cli_config_shows_effective_settings(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PROJECT_LOG_FORMAT", "json") + + result = runner.invoke(app, ["config"]) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == {"name": "template-python", "log_level": "INFO", "log_format": "json"} + + +def test_cli_config_masks_secrets(monkeypatch: pytest.MonkeyPatch) -> None: + class SettingsWithSecret(ProjectSettings): + api_key: SecretStr = SecretStr("do-not-print") + + monkeypatch.setattr(cli, "get_project_settings", SettingsWithSecret) + + result = runner.invoke(app, ["config"]) + + assert result.exit_code == 0 + assert "do-not-print" not in result.stdout + assert json.loads(result.stdout)["api_key"] == "**********" + + +def test_module_entry_point(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + monkeypatch.setattr(sys, "argv", ["template_python", "hello", "--name", "Module"]) + + with pytest.raises(SystemExit) as exc_info: + runpy.run_module("template_python", run_name="__main__") + + assert exc_info.value.code == 0 + assert capsys.readouterr().out == "Hello, Module!\n" diff --git a/tests/test_log.py b/tests/test_log.py new file mode 100644 index 0000000..ff43f1a --- /dev/null +++ b/tests/test_log.py @@ -0,0 +1,46 @@ +import json +import logging + +import pytest + +from template_python.log import configure_logging + + +def test_configure_logging_emits_text_once(capsys: pytest.CaptureFixture[str]) -> None: + configure_logging("INFO") + + logging.getLogger("template.test").info("ready") + + captured = capsys.readouterr() + assert captured.err.count("ready") == 1 + assert "[INFO] template.test: ready" in captured.err + + +def test_configure_logging_emits_json(capsys: pytest.CaptureFixture[str]) -> None: + configure_logging("warning", "json") + + logger = logging.getLogger("template.test") + logger.info("hidden") + try: + raise RuntimeError("boom") # noqa: TRY301 + except RuntimeError: + logger.exception("failed") + + lines = capsys.readouterr().err.splitlines() + assert len(lines) == 1 + record = json.loads(lines[0]) + assert record["level"] == "ERROR" + assert record["logger"] == "template.test" + assert record["message"] == "failed" + assert "RuntimeError: boom" in record["exception"] + assert record["timestamp"].endswith("+00:00") + + +def test_configure_logging_rejects_unknown_level() -> None: + with pytest.raises(ValueError, match="unsupported log level"): + configure_logging("TRACE") + + +def test_configure_logging_rejects_unknown_format() -> None: + with pytest.raises(ValueError, match="unsupported log format"): + configure_logging("INFO", "xml") diff --git a/tests/test_logging.py b/tests/test_logging.py deleted file mode 100644 index bc06d1e..0000000 --- a/tests/test_logging.py +++ /dev/null @@ -1,19 +0,0 @@ -import logging - -import pytest - -from template_python.logging import configure_logging - - -def test_configure_logging_emits_once(capsys: pytest.CaptureFixture[str]) -> None: - configure_logging("INFO") - - logging.getLogger("template.test").info("ready") - - captured = capsys.readouterr() - assert captured.err.count("ready") == 1 - - -def test_configure_logging_rejects_unknown_level() -> None: - with pytest.raises(ValueError, match="unsupported log level"): - configure_logging("TRACE") diff --git a/tests/test_settings.py b/tests/test_settings.py index 22f07f0..6656065 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,21 +1,51 @@ +from pathlib import Path + import pytest from pydantic import ValidationError from template_python.settings import ProjectSettings +def test_settings_defaults() -> None: + settings = ProjectSettings(_env_file=None) + + assert settings.name == "template-python" + assert settings.log_level == "INFO" + assert settings.log_format == "text" + + def test_settings_use_prefixed_environment(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("PROJECT_NAME", "example") monkeypatch.setenv("PROJECT_LOG_LEVEL", "DEBUG") + monkeypatch.setenv("PROJECT_LOG_FORMAT", "json") settings = ProjectSettings(_env_file=None) assert settings.name == "example" assert settings.log_level == "DEBUG" + assert settings.log_format == "json" + + +def test_environment_overrides_dotenv(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + (tmp_path / ".env").write_text("PROJECT_NAME=from-file\nPROJECT_LOG_LEVEL=ERROR\n", encoding="utf-8") + monkeypatch.setenv("PROJECT_NAME", "from-env") + + settings = ProjectSettings() + + assert settings.name == "from-env" + assert settings.log_level == "ERROR" + + +def test_settings_reject_unknown_dotenv_entry(tmp_path: Path) -> None: + (tmp_path / ".env").write_text("PROJECT_UNKNOWN=1\n", encoding="utf-8") + + with pytest.raises(ValidationError): + ProjectSettings() -def test_settings_reject_invalid_log_level(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("PROJECT_LOG_LEVEL", "VERBOSE") +@pytest.mark.parametrize(("variable", "value"), [("PROJECT_LOG_LEVEL", "VERBOSE"), ("PROJECT_LOG_FORMAT", "xml")]) +def test_settings_reject_invalid_values(monkeypatch: pytest.MonkeyPatch, variable: str, value: str) -> None: + monkeypatch.setenv(variable, value) with pytest.raises(ValidationError): ProjectSettings(_env_file=None) diff --git a/uv.lock b/uv.lock index a19d80c..0922cef 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,12 @@ resolution-markers = [ "python_full_version < '3.14'", ] +[[package]] +name = "actionlint-py" +version = "1.7.12.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/75/36ee89db39bb3620d1e46fe1f6ec77d6000d30ff45345699e51b4c850e83/actionlint_py-1.7.12.25.tar.gz", hash = "sha256:83f83be4cd317c79b15cbb7022787513a6994f00bfb2dd8da02fa4e62b4f640d", size = 12438, upload-time = "2026-09-22T16:24:42.477Z" } + [[package]] name = "annotated-doc" version = "0.0.5" @@ -259,6 +265,33 @@ css = [ { name = "tinycss2" }, ] +[[package]] +name = "boolean-py" +version = "5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" }, +] + +[[package]] +name = "cachecontrol" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" }, +] + +[package.optional-dependencies] +filecache = [ + { name = "filelock" }, +] + [[package]] name = "certifi" version = "2026.10.23" @@ -622,6 +655,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/91/1805cc144497d76a436f66a21fa57492bee7ed1257826fb992f03b9b2b6f/coverage-7.17.2-py3-none-any.whl", hash = "sha256:e674027ceb6bfd959f1bb035000fd20fa8b717dc6765adaaf8bca347822c8c7d", size = 219280, upload-time = "2026-11-04T19:52:32.726Z" }, ] +[[package]] +name = "cyclonedx-python-lib" +version = "11.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "license-expression" }, + { name = "packageurl-python" }, + { name = "py-serializable" }, + { name = "sortedcontainers" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/40/6509e6cfd7f2f3255501690f46375fdd224949e21fd1e96f4f4c8a9041b1/cyclonedx_python_lib-11.12.0.tar.gz", hash = "sha256:16767c4039de90c04e9f03348f8f0ed4b8ff842eaa7eefcad3a95685f970dacf", size = 1445378, upload-time = "2026-08-13T07:52:18.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/f0/b2cb999244f2f4194df63ecff6939228178fa63a33be809c412684ca8db7/cyclonedx_python_lib-11.12.0-py3-none-any.whl", hash = "sha256:0e807521a921a5c3cb8ce1153f8a61d29eedfe76a46aac2796b7c6b573391a54", size = 529453, upload-time = "2026-08-13T07:52:16.836Z" }, +] + [[package]] name = "debugpy" version = "1.8.22" @@ -674,6 +723,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/0d/6caab33488dc9d26440c24221fa610c7c8b83c30715ab45d31f0e004308e/fastjsonschema-2.22.3-py3-none-any.whl", hash = "sha256:f670b443830b75aca3f00b75949efa20f4b2a80302ec7a21505c28dd54cffd43", size = 28442, upload-time = "2026-11-01T21:11:29.306Z" }, ] +[[package]] +name = "filelock" +version = "4.2.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/1d/d730c664fcb4f1d264a722722a3134cf0881854f73c1fd58abf87572f232/filelock-4.2.9.tar.gz", hash = "sha256:96530b7c538d4ea08493ab067247aab7948c54d8cd622cce711c97cccc5d9896", size = 663116, upload-time = "2026-11-04T18:37:10.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/05/76184fe08339723d643ab47887a8e4de8f2921ca4e008617ab7490af7857/filelock-4.2.9-py3-none-any.whl", hash = "sha256:a379c50a729e5b68c8519a4d26489f755d9769960a236ecd838190e2a78db2e9", size = 164734, upload-time = "2026-11-04T18:37:08.273Z" }, +] + [[package]] name = "fqdn" version = "1.6.0" @@ -1177,6 +1235,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, ] +[[package]] +name = "license-expression" +version = "30.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boolean-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, +] + [[package]] name = "markdown" version = "3.11.3" @@ -1431,6 +1501,83 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/0b/43ff4afb6b438d47718b1959a22075ed95d8460d8c47381878b37a40de63/mkdocs_static_i18n-1.3.1-py3-none-any.whl", hash = "sha256:4036e24795a150c9c4d4b001ed24a43aec01335f76188dbe5a5d8fb4a27eba65", size = 21853, upload-time = "2026-02-20T10:42:40.551Z" }, ] +[[package]] +name = "msgpack" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/52/1ec7ead2c7528bf3d1db73e886fa4df10f9e6f0eb7854a448b7090cfcef7/msgpack-1.2.4.tar.gz", hash = "sha256:475c44e12f2a5de766a8448783f149c9b33f3f49b88953c6cd739a77d93c732c", size = 197764, upload-time = "2026-10-26T17:55:48.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/fd/45f6cd13cefbb659a8e0128ea64d51b349571163fec3c9c20f99792dc42a/msgpack-1.2.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:90cba4996b18ec1201fcaabd01ec48ff4b9755416bcd52105ded2f89fe145c49", size = 91903, upload-time = "2026-10-26T17:54:03.097Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/169c2a0b1ffeafe68a4f13d8d96b6ed1cd8d40d8262b3a6690851da7eda1/msgpack-1.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:112933726919f4239646cca9572e2c2fa5626358c670da8440aac09403100a48", size = 90573, upload-time = "2026-10-26T17:54:04.383Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/272a384f2a001cfced1baab0a8abdd912d6281432670f4609d1aab718d7b/msgpack-1.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ca1e78106675f2aed07dc37b842ce19be70010ab22c5c5c04688f0840d48539", size = 461057, upload-time = "2026-10-26T17:54:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d9/f59bd841fc2fd2b74dc50ce85860a6a25f58e588dd48946aeaf2862cf990/msgpack-1.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4d5bbdbea8f5669b3a734d6c226772804fb120848f99cbe63a0ddee63b049c9", size = 475652, upload-time = "2026-10-26T17:54:07.414Z" }, + { url = "https://files.pythonhosted.org/packages/00/03/afd000e1b1e28fd535dc8c7bc21ebb88923bc1964a2429fe660fb313ace3/msgpack-1.2.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d9ff7f3aaffd84010f1c501b05406a497584f5cab2f732eb9d89bf960e12f6f", size = 427082, upload-time = "2026-10-26T17:54:09.1Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7a/e2890b272883c496faad777324ddb70e9cc4a6c63731962a4a39a6a40f44/msgpack-1.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa10dc04c03408f31948ac1896a942141fd8d7a954b3612edac79f19663e4076", size = 451956, upload-time = "2026-10-26T17:54:10.476Z" }, + { url = "https://files.pythonhosted.org/packages/c2/46/0c1b240b8a580af3e72139c33b018025440a1a42c2579e82e39b1195ee36/msgpack-1.2.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:472a25463678f2795143a7cb42e88a1b59cc2375e9881917e69a9323bde42114", size = 423833, upload-time = "2026-10-26T17:54:12.195Z" }, + { url = "https://files.pythonhosted.org/packages/80/8e/851415b1a9264e6abf64d8a9617d856769d59e247fb9470106f133aedbae/msgpack-1.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cbb8a677da3e845713925e9a203b8a120b1e9da9989836890ba4a4a187902701", size = 470951, upload-time = "2026-10-26T17:54:13.764Z" }, + { url = "https://files.pythonhosted.org/packages/c9/74/d75bf61aab9cb88b84f7d3a8eca8dd92e19a742b77ded300a1177e88d031/msgpack-1.2.4-cp312-cp312-win32.whl", hash = "sha256:2b0b7dff5beddc10f16ecea6b28c6b98014d1135d7015bd83adff5037f35848d", size = 68786, upload-time = "2026-10-26T17:54:15.049Z" }, + { url = "https://files.pythonhosted.org/packages/81/c5/dd0e78eb04f9a0773a810d25ef2cd8f16bb941487567d0c63f814881133c/msgpack-1.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:6e38b123681df22373d545520f54bbeddc4706d9235a1161061f12bd6648497d", size = 77272, upload-time = "2026-10-26T17:54:16.491Z" }, + { url = "https://files.pythonhosted.org/packages/65/e7/b38f63ae82777c0077daa56c4b09af39d29dcb8927ab2ff59268094069c5/msgpack-1.2.4-cp312-cp312-win_arm64.whl", hash = "sha256:6795c588b210b2f13d62c8fcc23bf779b509b839ec5c197a7ab4f5191459e126", size = 71884, upload-time = "2026-10-26T17:54:17.995Z" }, + { url = "https://files.pythonhosted.org/packages/2d/26/16eaad5fe6e199334fa5bd66070778665717c86e9a273525dc706ddb06a7/msgpack-1.2.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fb811ac3d463a75013b234d2aae56b9f0bdb0105caf4dd2ee65305b6a8a6078d", size = 92057, upload-time = "2026-10-26T17:54:19.616Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b8/9660be6d120820bc05106f291e824c73d35f4ab77d927788ae67ea80889c/msgpack-1.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:36258fd0909e1bf26e94604439108846432abe4ffa9a23c9dc2f6f1c60f94b60", size = 90542, upload-time = "2026-10-26T17:54:20.965Z" }, + { url = "https://files.pythonhosted.org/packages/6c/10/cd55a69fec29df26884b178dc4b1803442ec82abf929604aac619881917a/msgpack-1.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9c5ba0f053a7e98f013f083c9d1b2dffb26cad8c897c533d8286e44ed24c669", size = 455798, upload-time = "2026-10-26T17:54:22.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/94/562e0733593a780c70b0b18994f30c205149f7f9f4efbe3e9b00b88612c5/msgpack-1.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fabd415c41e62006c6641fcdf5b3e866f581c4e11c58328c0d0f69584b9577d", size = 469262, upload-time = "2026-10-26T17:54:24.043Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/76d1a5c597e04e1d360b05573d150bdb4502f5a014e6e9cf089c9537c156/msgpack-1.2.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7433d340de45003db3ed909a642c9469b598d175530f7a707cbaba64287d27ce", size = 420789, upload-time = "2026-10-26T17:54:25.587Z" }, + { url = "https://files.pythonhosted.org/packages/8a/c6/b72b8d8022718380db459ced8ffbabc7570ad2d22c7f2fa659c872cb6734/msgpack-1.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccd6e4af5347eb9828147320449bcd30e3321f6397d4583ff16047485dd8be3d", size = 447068, upload-time = "2026-10-26T17:54:27.721Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/f378ef1def4aa135e70325c09e22457f3203ae2a390a192e892267906f33/msgpack-1.2.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:23d1459a1d0b4c9f6629df072d592eb74424b3ed3656b69af507bc9023cc027f", size = 418896, upload-time = "2026-10-26T17:54:29.325Z" }, + { url = "https://files.pythonhosted.org/packages/62/93/a7e507378f43e91415cb6fda47e0ded0c9de1618a1f4ac4f2887912a92dd/msgpack-1.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9e73761fe9fc55a4e9d2df8e8f59b0979231af4dbf80b8bf90cd64c74173d779", size = 465764, upload-time = "2026-10-26T17:54:30.719Z" }, + { url = "https://files.pythonhosted.org/packages/60/76/7f39f49cbcdaa4aafe64b4941b2640772dbdc1cac3a9332f4c9ba6994da9/msgpack-1.2.4-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:d088d1f552e129859db830692268747aba0a1ee3e6e0fe6344dbb3aab1df9f6c", size = 53615, upload-time = "2026-10-26T17:54:32.213Z" }, + { url = "https://files.pythonhosted.org/packages/67/9b/85b93350dbd7b52b358882f7f17a88a03664a7a59a359406e6315c162634/msgpack-1.2.4-cp313-cp313-win32.whl", hash = "sha256:892d4f17d7962bfd2c2928b0a47542bf2948f074a7c04721b73d4138b70481e4", size = 68724, upload-time = "2026-10-26T17:54:33.484Z" }, + { url = "https://files.pythonhosted.org/packages/02/4a/a7e2778ce09a943c17b6f91c7acb2f9488357657c01f2cccb278297621c4/msgpack-1.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:318d4e634d2b320be10decd9968ad1ae0be1d80cba23b5528c37b429eeb0e3e0", size = 77039, upload-time = "2026-10-26T17:54:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/9ff7495a9853e9d7de74965fdadce15017e7213ce564bb5dafc5403c9108/msgpack-1.2.4-cp313-cp313-win_arm64.whl", hash = "sha256:bec3e34749b90cfcf116149aad11295c720ff4a74a7d0a35abdb968a396c1524", size = 71873, upload-time = "2026-10-26T17:54:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/86/34/747f9ea2b428088201c73fcb3a885cebcbedcd71def0f1f3c36896294b27/msgpack-1.2.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:895e9369185df4a874768849405c30cba7990caceb3fcb1692ebe62bb050f55e", size = 92449, upload-time = "2026-10-26T17:54:37.503Z" }, + { url = "https://files.pythonhosted.org/packages/83/7e/52d208cb1561f869762c17be0e2c004e4d34f2842d239a450492d6246d62/msgpack-1.2.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c97f5f17407e1761f23f1944be33cbf1d5ce67017db32544dcd5e16e0b1b4a3b", size = 91202, upload-time = "2026-10-26T17:54:38.797Z" }, + { url = "https://files.pythonhosted.org/packages/2e/58/5516cabb60da83506024015c669622a0277979aef51eb611308cebaf4cc9/msgpack-1.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2444a4e04f924630d059ae6eae8f91fdfcd28ed9fd17b21bacf4e74550a091f7", size = 455185, upload-time = "2026-10-26T17:54:40.213Z" }, + { url = "https://files.pythonhosted.org/packages/39/8f/bf975b5d2111e4954a0f8567fc6b60bab22c8766dd4fa509af1334c91a4f/msgpack-1.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d65b791ce4283e02b6267742ad540899155e1b32887b9cbdda47f38f5a076b5", size = 465078, upload-time = "2026-10-26T17:54:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/32/2f/c74baeafc53d1c1fadfbeb774444d0e6354778d12b40b827e437a7a35856/msgpack-1.2.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5e4c67dbac05dce90a3fa1d845137617383c415e6b319062baac9734540706ae", size = 420144, upload-time = "2026-10-26T17:54:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f2/bdf2ce9e284821397dc5549101824c7607ee6ba6e0635390d168a77ac49c/msgpack-1.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:781e856db4f2cb398e4992e2a74305295d3ebf694eec26fa0f4bc766eb9fa0a0", size = 446479, upload-time = "2026-10-26T17:54:46.484Z" }, + { url = "https://files.pythonhosted.org/packages/76/8d/b4572d24bb75d98bcbbd031a934a3a1d03494ea88a100cd51ab16c4c7d57/msgpack-1.2.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cfc1cc237792f9a5664002b66acb961027d4184528ec9d10e0e42330f7972383", size = 418780, upload-time = "2026-10-26T17:54:48.04Z" }, + { url = "https://files.pythonhosted.org/packages/55/8e/4493bef4422730cfd3420b84094652fabd27ef8aadc8b443a8995b524d08/msgpack-1.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f60de6ae13d4d2a12a483f15c5e47cb6fa2731aa32d92fb31178e2017d6574a8", size = 462009, upload-time = "2026-10-26T17:54:49.433Z" }, + { url = "https://files.pythonhosted.org/packages/41/29/efdc3d1d09e4e0e45955b0aad559b4c1f5d908767c9fb1021990c52182a5/msgpack-1.2.4-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:06a0657a4110d563255006b7cb7147432f67827144e429c2cdb1c967548d1817", size = 53714, upload-time = "2026-10-26T17:54:50.753Z" }, + { url = "https://files.pythonhosted.org/packages/df/0b/08794c65e0a752acf20aa44e193e61dfb552b48fe6c27ba6023c146f9e77/msgpack-1.2.4-cp314-cp314-win32.whl", hash = "sha256:727a65b35db9f5ef516fc0229c4b05cab983092b1ae274a8a769b2d517fb0499", size = 70726, upload-time = "2026-10-26T17:54:52.397Z" }, + { url = "https://files.pythonhosted.org/packages/b5/da/e2cc63ec61e7ac05595b391cba01c10cb4657f4e517da1512e4a75d2d12e/msgpack-1.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:80f5d4fda1ba373d95933f98e6c1d18247feffac839fda3930bdd85e6561bb5f", size = 78346, upload-time = "2026-10-26T17:54:54.662Z" }, + { url = "https://files.pythonhosted.org/packages/93/e4/1b0eb49887009dc2c530171d659cf2aa37202b5f79c0b784e63777554b37/msgpack-1.2.4-cp314-cp314-win_arm64.whl", hash = "sha256:e5f67f0286f2ba2c6dbb4998404ed880112ca204ceda729cf239879cd89d3acf", size = 74152, upload-time = "2026-10-26T17:54:55.915Z" }, + { url = "https://files.pythonhosted.org/packages/d0/4a/6defc8fd0270c4ba675e6625f4f76ce936fd0c48733395fd7ba912cd423e/msgpack-1.2.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:45506bfb902cde24a0d33fb0581f58497ddb2682686dd1f6aa07e7b769df7ca4", size = 94083, upload-time = "2026-10-26T17:54:57.108Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/59f7e076739048864710a4de3403ae1c1b43ed5077b796cd9e8cbce9b3f6/msgpack-1.2.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dc9dc681958e69f46b9f37def3e7fd6cc5dcfaa5943fb00bfe9c223a1a1052f6", size = 95037, upload-time = "2026-10-26T17:54:58.489Z" }, + { url = "https://files.pythonhosted.org/packages/2c/dd/dbae250b23b831cd0571eb664089f80de3249a39e98c6ef55f9a0efe928d/msgpack-1.2.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bef691e2a95119cc6bf3ded29325dbd89c4b6f8ff233070431948ed70902421", size = 472722, upload-time = "2026-10-26T17:54:59.924Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e2/383ece1c1b27c1ed322055fce8ab8972bc6d529ede881592065ce0c461bf/msgpack-1.2.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55e6106d2d085af42b2de72efeb38ed255241a24b32295ca889707c1d679c442", size = 468726, upload-time = "2026-10-26T17:55:01.492Z" }, + { url = "https://files.pythonhosted.org/packages/37/2f/5410a8815ae74a824ac87543c612e40cb6674851b70a6ad858bb32813203/msgpack-1.2.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:996dfc4774c240c8e6adf1166ae102141cfbed33c8856fde821bd6c0bd35ee41", size = 420488, upload-time = "2026-10-26T17:55:02.848Z" }, + { url = "https://files.pythonhosted.org/packages/39/8e/16fdf02b4f91dff9c1480433c1bf80756c2fedc0b74fc6a231f02dfed007/msgpack-1.2.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:efa1357fb0c7201e833de28638be371ee127476f7ccff9e7a94211b0afc33ddf", size = 455541, upload-time = "2026-10-26T17:55:04.564Z" }, + { url = "https://files.pythonhosted.org/packages/c4/76/2454ef54d780a167667552d02e7bc977a3d419943bcbdf6a96a4da6786ad/msgpack-1.2.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b9270b63409e70c8c6deb5faedc81fc27c6bd0196b38084a8773f68286abf034", size = 419514, upload-time = "2026-10-26T17:55:06.257Z" }, + { url = "https://files.pythonhosted.org/packages/ac/8b/2af9bf378def33c24875f250421b0d0b1b06f72a1c2c3f964749fc4cdee1/msgpack-1.2.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2b597bb01aff61d65e305c2cf2589c93c86609929d42d575bfc359f3c54ea8e1", size = 461910, upload-time = "2026-10-26T17:55:08.05Z" }, + { url = "https://files.pythonhosted.org/packages/0e/46/a133ac6c4cef4d3939383c00cc19baa16ee85fd70af0c5990e7a9ca8767f/msgpack-1.2.4-cp314-cp314t-win32.whl", hash = "sha256:ad5bc540cbc34db236f8535721a9a872d12a9d88d1322470f1fc7c5e9dadfdb2", size = 76441, upload-time = "2026-10-26T17:55:09.711Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f5/8d22ec454c188f1795107a3fd9f68ada485076ebf394a4aaaa5fbb6093e7/msgpack-1.2.4-cp314-cp314t-win_amd64.whl", hash = "sha256:287e52c2ebaa94f8eca045628374859f51584e77bc07086e6835a9bb2204c774", size = 84321, upload-time = "2026-10-26T17:55:10.947Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/1390514aee47d14ca3fe6ea0cf236fddc55021867ace30daf8761deba36f/msgpack-1.2.4-cp314-cp314t-win_arm64.whl", hash = "sha256:f3f19fe5decd348b35b003ae5f32d3fa90877413a0183da676f748a3a7ab9270", size = 78589, upload-time = "2026-10-26T17:55:12.425Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d8/0465d475c1a7063af1a1fb4f8c69e0dd661c4dd172055c9757a343a07d5d/msgpack-1.2.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:765584b93ed6f0b561f0c56e9735f24945c0682f857c9b3ee3b4caad9c32f66a", size = 92449, upload-time = "2026-10-26T17:55:13.665Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d0/5220a8a53579dda39f3526394ba5b7eafc28736085f2626deda7fcd22cd1/msgpack-1.2.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:0221748c30ace75fde0dec8c470abe65fb5374cd4f37adb9db6aca7a3c362c0a", size = 91209, upload-time = "2026-10-26T17:55:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/b2/98/f151742eba413503074a10d8f636bf03dd4f3f7cba29689767a35d19bd1c/msgpack-1.2.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5917972ce1bf5f85f761031e00dab6fe0ad9f880cb3d2d43ed74b330c850400", size = 455713, upload-time = "2026-10-26T17:55:16.406Z" }, + { url = "https://files.pythonhosted.org/packages/b0/54/890510a2c9841aff63258e085d15d4e140c7eef559d4da81ef2d2d867436/msgpack-1.2.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7ae8cf6012372cecb43016132d1b87ccc2ebb8ae5ab5078687399e82423386a", size = 466177, upload-time = "2026-10-26T17:55:18.351Z" }, + { url = "https://files.pythonhosted.org/packages/3d/41/a6f20d7c2c380fb072b95ffc5b865a1d62eaf49d77fbee44466c45a1a8de/msgpack-1.2.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e78c66cf97ac5675037f705ca433ad98ac9177f9523e7fdbb34e486ae29044c8", size = 424398, upload-time = "2026-10-26T17:55:19.979Z" }, + { url = "https://files.pythonhosted.org/packages/89/86/fa9559961f2b0d498e6cefa226298ddee2b896cb223449c211702bc34071/msgpack-1.2.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:59887281c3c163a7afff7dffaaad2f78cd2b513b7e1a7843f1ee8e7cd6a94231", size = 445686, upload-time = "2026-10-26T17:55:21.775Z" }, + { url = "https://files.pythonhosted.org/packages/39/23/5a9d94036cdd77d4310bbb7cfaf3ccd1f087c4a5d5a4befec4361c5fc0dc/msgpack-1.2.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9f46e70c8d22e96856967ab9d80c51dcf5bc63133e4da2d66fab64c0d4119b82", size = 422096, upload-time = "2026-10-26T17:55:24Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4b/86a5384112509a94ff6e20028aba6845138743effb7c6617e959ad1256ab/msgpack-1.2.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:0a1fd6c31952155b7cd01061288c70303e6554ae855244dfb38a897fd94f8749", size = 463407, upload-time = "2026-10-26T17:55:26.146Z" }, + { url = "https://files.pythonhosted.org/packages/f4/cf/14db289e0ad531e22ce0f03284865f81d14fbc80bf3f79d3b1714bcf76cf/msgpack-1.2.4-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:b1f08ec826417e3c8dc3edadb50f38debba033af6cb849229f0eac72f80f2342", size = 53743, upload-time = "2026-10-26T17:55:27.497Z" }, + { url = "https://files.pythonhosted.org/packages/46/72/a76fc90b51503c0bdacbc7fc823aa6fa62d40596ac47f9ea8492d6f95a34/msgpack-1.2.4-cp315-cp315-win32.whl", hash = "sha256:e8ba23c838739fe929e868095171e71f2f38d6ce0fcf5840b576fb82648e58e6", size = 70780, upload-time = "2026-10-26T17:55:28.705Z" }, + { url = "https://files.pythonhosted.org/packages/58/a7/fa9d4a1b5dfead83ea6e03b4007041a5da988c25eb941d42612e0211d3c0/msgpack-1.2.4-cp315-cp315-win_amd64.whl", hash = "sha256:67310606cf2528a6dc3ece1298dcd5cc6c57add148b16d22c5111138aa84820c", size = 78288, upload-time = "2026-10-26T17:55:29.928Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f3/8163d7938a8fe003952fe2719ddb31da71998559bbe290c55ffe622b886b/msgpack-1.2.4-cp315-cp315-win_arm64.whl", hash = "sha256:ce76e7a7c11459157887a8dadf07c107ff74eca98cf914f0de6ee7ab4b47104d", size = 74235, upload-time = "2026-10-26T17:55:31.112Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fa/37c3219b250d81bee788c0dd78ae2fc673db2da1c21ae51f7982d85698f7/msgpack-1.2.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:fb9ddbf23b8c6e89752af399fcf4daed60055e239cda44c6acfaa76d19331e8c", size = 93793, upload-time = "2026-10-26T17:55:32.304Z" }, + { url = "https://files.pythonhosted.org/packages/83/9f/b8afcd20d32cf4be1fa3c327e997656d6d446e667452b0fcc66b91710e82/msgpack-1.2.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e283cf563166c21762131b88e3870ee2ec57757b08a8736929757b5a67541061", size = 94746, upload-time = "2026-10-26T17:55:33.624Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4f/03348d381fcbbc2685356833408cc7e9d2af0fe0775926935a2889650f64/msgpack-1.2.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e91bf8325f492e8eb3c3aa6de06b20593c225336bb52b585c913d7c1da025b", size = 468480, upload-time = "2026-10-26T17:55:34.991Z" }, + { url = "https://files.pythonhosted.org/packages/16/46/e54af8d9a51fb562e068e69a0d75a51e9c2fedec49f911f888515f5477ab/msgpack-1.2.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfa86432bf329d6b976f5a00ad4c9064fd51736e4967a52526750236345cc2da", size = 470064, upload-time = "2026-10-26T17:55:36.615Z" }, + { url = "https://files.pythonhosted.org/packages/74/f5/8f9a6b3af1d803e832b11017c8dad44d761fefdb91dba04ee58cb51838da/msgpack-1.2.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:42e0a7dc26df739149478585d4264338b4d330f24b3e3b57d5aa89e48866e8ab", size = 421267, upload-time = "2026-10-26T17:55:38.076Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8f/7302b0d18eadbbfef9be1dbbdc49905f7f3b847200c3e47068d093976be4/msgpack-1.2.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8e680367090a58c966052d61418cc943111863538fd82471d61fca5271d911e1", size = 451244, upload-time = "2026-10-26T17:55:39.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/c6/05717fb56b510b5e974446f2cb8e9b26fa4347fafc0c5927745b176939b3/msgpack-1.2.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:a6c9926f8871b261622fe6b599db72c69be5bca8fdc0976886f9a531a301dee9", size = 420273, upload-time = "2026-10-26T17:55:41.488Z" }, + { url = "https://files.pythonhosted.org/packages/05/d9/a19077d664c37319097e16ba8de14e6b3a2edcc8ee78a2069c0808e38960/msgpack-1.2.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c50bac325dbf1a9c80e10da6dbd1100d7082f6b12e153084501e18e86cd3a037", size = 460421, upload-time = "2026-10-26T17:55:43.223Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bd/0e2033a7435f8d2311be37363fedc88bdfa9fd95c364ee8ac3216eb63e36/msgpack-1.2.4-cp315-cp315t-win32.whl", hash = "sha256:34051de19ae532ba47fcab5b0abd08c25c7a64ff763ad819f18764d45f3c2c81", size = 76256, upload-time = "2026-10-26T17:55:44.588Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/aa7265f25dafb6af7567a1a7d0a95c5566803db2226c5e084dc5850cfd96/msgpack-1.2.4-cp315-cp315t-win_amd64.whl", hash = "sha256:6ad2f0808099d4281208ff2c756efa958a55a7c8eb21fe1c7052b95c584f02f1", size = 83939, upload-time = "2026-10-26T17:55:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f2/c53904c4bc3da828fb75678bf489ece808a277affa73f8a7a91dfb7c5851/msgpack-1.2.4-cp315-cp315t-win_arm64.whl", hash = "sha256:e38d854f1c60e94f46d998765201126338bde3fe1b029be0d849094bcbef72c2", size = 77995, upload-time = "2026-10-26T17:55:47.016Z" }, +] + [[package]] name = "mypy" version = "2.4.1" @@ -1570,6 +1717,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307, upload-time = "2024-02-14T23:35:16.286Z" }, ] +[[package]] +name = "packageurl-python" +version = "0.17.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -1627,6 +1783,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] +[[package]] +name = "pip" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/c7/f8d9e2e0e7c1ed8a22a10eeb3b5bf7860bc07d8446c7a978a882c5c8214b/pip-26.3.tar.gz", hash = "sha256:6c36ae7da8695be67e3cd9f7d87919667ced53f57ce2ede75d89b5912ed2bc4c", size = 1934348, upload-time = "2026-11-02T20:34:27.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/25/ee8c38ae72d66b844ce8eb9711ba905dba21ca1a8a0c960057c41cd23337/pip-26.3-py3-none-any.whl", hash = "sha256:f3b60b20ffaadd4415ed7957212a38ac63fa4c5fa375bd0f4a797777940fad13", size = 1950951, upload-time = "2026-11-02T20:34:25.728Z" }, +] + +[[package]] +name = "pip-api" +version = "0.0.35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/08/47dfc28aa16ef0dbc38dd7c6e4fe24d124c3ed3a722a4a3e5021584a588c/pip_api-0.0.35.tar.gz", hash = "sha256:14dea05d7b3b68f4c7582213e3ae8932bbba8e1f754bf06b95263d5fa6ab4363", size = 123884, upload-time = "2026-09-08T19:35:12.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/2e/d424c5b5877be862dd9a2c8b7e9ddb13382bb25e132e6dd50cc09c0a7dde/pip_api-0.0.35-py3-none-any.whl", hash = "sha256:66990d0da25378555cbb538db79384bab92d4617c6350dfd7a2126ba2a3a9894", size = 123943, upload-time = "2026-09-08T19:35:11.537Z" }, +] + +[[package]] +name = "pip-audit" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachecontrol", extra = ["filecache"] }, + { name = "cyclonedx-python-lib" }, + { name = "packaging" }, + { name = "pip-api" }, + { name = "pip-requirements-parser" }, + { name = "platformdirs" }, + { name = "requests" }, + { name = "rich" }, + { name = "tomli" }, + { name = "tomli-w" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a4/f21d5f0a0edabcbce31560b73c7c5a6f72ae87af4236fd1069c8f59a353d/pip_audit-2.10.1.tar.gz", hash = "sha256:1eb4565d19ebe5d48996f4b770b4d2b32887e12cb12cfa637f1a064011b55ffc", size = 54275, upload-time = "2026-06-10T22:17:01.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/a7/b0c504148114047bd1bc9d97447453c6850ca176bb2f3c0038835994e8b7/pip_audit-2.10.1-py3-none-any.whl", hash = "sha256:99ef3f600a317c1945f1e89e227ef26e1c2d618429b8bd3fa6f4f7c440c4611a", size = 62023, upload-time = "2026-06-10T22:17:00.309Z" }, +] + +[[package]] +name = "pip-requirements-parser" +version = "32.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" }, +] + [[package]] name = "platformdirs" version = "4.14.5" @@ -1729,6 +1940,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/18/83376915176eb058cb86470eb7396388a13350b09a8f233a79303bbcbc5b/pure_eval-0.2.4-py3-none-any.whl", hash = "sha256:96cae060a313cfaad51bb761278bfb0e62dc0248d9315a81173752dc546cd37a", size = 11893, upload-time = "2026-09-10T21:41:21.532Z" }, ] +[[package]] +name = "py-serializable" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/8c/cad9596066de835b304be48b0eefa30dae1834afae6e670805831cc05ea2/py_serializable-2.2.0.tar.gz", hash = "sha256:8e4fa02e8de819c0488ccdb69c2d3109274adcf224c02357fd8729cb12727aab", size = 52608, upload-time = "2026-11-06T13:43:39.943Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/56/94f6a9ac81193713e6be0d41c62d6b916b53c814c05d8b3a0d90976db0d6/py_serializable-2.2.0-py3-none-any.whl", hash = "sha256:2cce775abc5aac12115f9ff572a145c7276d04a770090334bd4e51ed9c782415", size = 23101, upload-time = "2026-11-06T13:43:38.593Z" }, +] + [[package]] name = "pycparser" version = "3.12" @@ -1899,6 +2122,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/47/e96d2b3439b9fab6dc085747f25aee0cbae2bae16743e86ca60907bd0d59/pymdown_extensions-13.0-py3-none-any.whl", hash = "sha256:c68f6e69b14604fb55227735bd0c4c4d3d1d9299a2d1314352190c48aa0d1040", size = 280470, upload-time = "2026-11-08T14:20:27.756Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" }, +] + [[package]] name = "pytest" version = "9.2.0" @@ -2326,6 +2558,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "soupsieve" version = "4.0.1" @@ -2354,13 +2595,16 @@ name = "template-python" version = "0.0.13" source = { editable = "." } dependencies = [ + { name = "pydantic" }, { name = "pydantic-settings" }, { name = "typer" }, ] [package.dev-dependencies] dev = [ + { name = "actionlint-py" }, { name = "mypy" }, + { name = "pip-audit" }, { name = "prek" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -2382,13 +2626,16 @@ test = [ [package.metadata] requires-dist = [ + { name = "pydantic", specifier = ">=2.12,<3" }, { name = "pydantic-settings", specifier = ">=2.15,<3" }, { name = "typer", specifier = ">=0.27,<1" }, ] [package.metadata.requires-dev] dev = [ + { name = "actionlint-py", specifier = ">=1.7.12,<2" }, { name = "mypy", specifier = ">=2.4,<3" }, + { name = "pip-audit", specifier = ">=2.10,<3" }, { name = "prek", specifier = ">=0.5.4,<0.6" }, { name = "pytest", specifier = ">=9.1,<10" }, { name = "pytest-cov", specifier = ">=7.1,<8" }, @@ -2432,6 +2679,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, ] +[[package]] +name = "tomli" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6", size = 17662, upload-time = "2026-10-07T12:23:37.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/ed/7baa86f87493646a594de388c7c1c40a39dd0461f7e9c0359cbeefc91fe8/tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df", size = 164854, upload-time = "2026-10-07T12:22:27.444Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b1/44c0341f2224397855723c7a8a39f718ea6fcbcc3dacc66e5aeca0f334e3/tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b", size = 164074, upload-time = "2026-10-07T12:22:28.679Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e2d5b7d3fba47adedb23de616c16d428ea076c79a3d8e1d95d649ffe197e/tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0", size = 274274, upload-time = "2026-10-07T12:22:29.804Z" }, + { url = "https://files.pythonhosted.org/packages/43/90/6090e706ff27a6f89f4a40578e3324b95c3cd8c4150868aabf33a8f414c3/tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6", size = 286435, upload-time = "2026-10-07T12:22:31.297Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9e/a2c40768df16c408f22430afb0a73e9d7e5f79c950884954649d1146b74d/tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc", size = 278119, upload-time = "2026-10-07T12:22:32.601Z" }, + { url = "https://files.pythonhosted.org/packages/12/25/3c0cb485b98e9cfac495629b1c93c87ccf0b72fbe9d2689fd8fe62c6d5a3/tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7", size = 286177, upload-time = "2026-10-07T12:22:33.745Z" }, + { url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760, upload-time = "2026-10-07T12:22:34.887Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722, upload-time = "2026-10-07T12:22:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534, upload-time = "2026-10-07T12:22:37.296Z" }, + { url = "https://files.pythonhosted.org/packages/42/e5/51736d70da209350969e15aca5c5ab6e2ce1ea87a0a892a6c13aec172a86/tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea", size = 163328, upload-time = "2026-10-07T12:22:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/086f80dab4ab497602644274e6dea7ec5dd0b4e262e443a8ad3bb7edee2d/tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043", size = 162246, upload-time = "2026-10-07T12:22:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/aa/eb/3ecc94459f3635c92321f4e7bde571323fdb2267c50e19e3188a281eae3b/tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0", size = 272655, upload-time = "2026-10-07T12:22:41.08Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d7/494fd1f0c37a621f1ad9975c2efadb523e8101f144ed6edb2e7fe64738f2/tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b", size = 283595, upload-time = "2026-10-07T12:22:42.222Z" }, + { url = "https://files.pythonhosted.org/packages/70/51/bb8d62b1317e6640866f6949b2d5855e5300f2c99d46de1cd245570bba65/tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066", size = 276253, upload-time = "2026-10-07T12:22:43.625Z" }, + { url = "https://files.pythonhosted.org/packages/66/f4/f46bd7f0763cd47de2db697dca9257c6a4adfd1a93b018cc75c8190ed5a8/tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b", size = 283582, upload-time = "2026-10-07T12:22:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628, upload-time = "2026-10-07T12:22:46.508Z" }, + { url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301, upload-time = "2026-10-07T12:22:47.647Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744, upload-time = "2026-10-07T12:22:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/caf6b307766eb9567664a8707e9d6be5fcc0e8903f18781c6677a60d80c7/tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105", size = 162899, upload-time = "2026-10-07T12:22:50.088Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c8/68cfce773a2733a49c74f99d627fb461bd990756860099eac25617889585/tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646", size = 162080, upload-time = "2026-10-07T12:22:51.558Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b2/e5bb8651fdad593f670501a7d718b1a7f73f064d44dea15e04c04dfef45d/tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b", size = 273380, upload-time = "2026-10-07T12:22:52.918Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d2/9e2d7f8b1dfe0e2b34c245986ebd55c4c553ea4ce6c47c443b332673253f/tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75", size = 283228, upload-time = "2026-10-07T12:22:54.173Z" }, + { url = "https://files.pythonhosted.org/packages/ba/df/ec7b876b7b1a2718bd74a3743c076fff565b04029ba33e8f61fac262739f/tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb", size = 277189, upload-time = "2026-10-07T12:22:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/e192d9eed0b9cb80da799f4d77052297fb9a2c3cc9b19f571f56ea88add6/tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3", size = 283632, upload-time = "2026-10-07T12:22:56.735Z" }, + { url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535, upload-time = "2026-10-07T12:22:58.084Z" }, + { url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621, upload-time = "2026-10-07T12:22:59.2Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572, upload-time = "2026-10-07T12:23:00.479Z" }, + { url = "https://files.pythonhosted.org/packages/b9/20/1f88f19427d380a40e90a770e087489eaafe4aeee070ae88ed2bbec00acd/tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4", size = 171814, upload-time = "2026-10-07T12:23:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/cbe5079c9f9a54b9b3e27fc82f08f3cb36edee75561679f53d2380c801d6/tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d", size = 171324, upload-time = "2026-10-07T12:23:03.18Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/1d53fd3b0f1cb3ba542e345ec32c26aefdddc4e829e4f3429af8a4f27782/tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9", size = 297441, upload-time = "2026-10-07T12:23:04.345Z" }, + { url = "https://files.pythonhosted.org/packages/66/d9/0800acb6a111686f764c1b91ef15cc42a20a66a46013bb42220f1d2c61c1/tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f", size = 307476, upload-time = "2026-10-07T12:23:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/30a8f3cd51b5bec37f04744bad0b0dc6160df84aad4f27b0e9283d66f221/tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374", size = 296113, upload-time = "2026-10-07T12:23:07.202Z" }, + { url = "https://files.pythonhosted.org/packages/ab/18/0b9ffc597e69c5a1e20a7823cb60d54b39a9f54e91edcb8574f022186758/tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442", size = 307725, upload-time = "2026-10-07T12:23:08.508Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546, upload-time = "2026-10-07T12:23:09.956Z" }, + { url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814, upload-time = "2026-10-07T12:23:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188, upload-time = "2026-10-07T12:23:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc", size = 162775, upload-time = "2026-10-07T12:23:13.941Z" }, + { url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276", size = 161406, upload-time = "2026-10-07T12:23:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52", size = 273855, upload-time = "2026-10-07T12:23:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7", size = 284910, upload-time = "2026-10-07T12:23:18.166Z" }, + { url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391", size = 277723, upload-time = "2026-10-07T12:23:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859", size = 285115, upload-time = "2026-10-07T12:23:20.698Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475, upload-time = "2026-10-07T12:23:21.941Z" }, + { url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589, upload-time = "2026-10-07T12:23:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493, upload-time = "2026-10-07T12:23:24.233Z" }, + { url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57", size = 171380, upload-time = "2026-10-07T12:23:25.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd", size = 170553, upload-time = "2026-10-07T12:23:26.855Z" }, + { url = "https://files.pythonhosted.org/packages/53/aa/3056c919eb3e084df3752b2cf5f865dcc04af0b27dba2f66d7b28af4633a/tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01", size = 294428, upload-time = "2026-10-07T12:23:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/faeeb5d8769ea3832021d73e892c8391eae7b4b4f8b55a789127bd8b18a9/tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f", size = 304909, upload-time = "2026-10-07T12:23:29.381Z" }, + { url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a", size = 293220, upload-time = "2026-10-07T12:23:30.608Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142", size = 305705, upload-time = "2026-10-07T12:23:32.181Z" }, + { url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432, upload-time = "2026-10-07T12:23:33.496Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281, upload-time = "2026-10-07T12:23:34.648Z" }, + { url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069, upload-time = "2026-10-07T12:23:35.77Z" }, + { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + [[package]] name = "tornado" version = "6.6"