diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e5374c7..4034eba 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,11 +1,14 @@ version: 2 updates: -- package-ecosystem: pip +# The `uv` ecosystem understands pyproject.toml *and* uv.lock. The `pip` +# ecosystem does not update uv.lock, so transitive pins there (which CI +# installs, because every job runs with --locked) would never be bumped. +- package-ecosystem: uv directory: / schedule: interval: weekly day: monday - time: 06:00 + time: '06:00' timezone: Europe/Rome open-pull-requests-limit: 10 commit-message: @@ -21,7 +24,7 @@ updates: schedule: interval: weekly day: monday - time: 06:30 + time: '06:30' timezone: Europe/Rome open-pull-requests-limit: 5 commit-message: @@ -37,7 +40,7 @@ updates: schedule: interval: weekly day: monday - time: 07:00 + time: '07:00' timezone: Europe/Rome open-pull-requests-limit: 5 commit-message: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..3bb5550 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,86 @@ +name: Release + +on: + push: + tags: + - v* + +permissions: + contents: read + +env: + PYTHON_VERSION: '3.10' + +jobs: + release: + name: Publish GitHub Release + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: ./.github/actions/setup-python-uv + with: + python-version: ${{ env.PYTHON_VERSION }} + # tomllib is 3.11+, and PYTHON_VERSION is 3.10 to match this template's + # floor, so the version is read with a regex instead. The pattern mirrors + # VERSION_LINE in scripts/release.py, which writes this same line. + - name: Verify tag matches the project version + run: | + tag_version="${GITHUB_REF_NAME#v}" + project_version=$(uv run --locked python -c \ + "import pathlib, re; print(re.search(r'(?m)^version\s*=\s*\"([^\"]+)\"', pathlib.Path('pyproject.toml').read_text()).group(1))") + if [ "$tag_version" != "$project_version" ]; then + echo "::error::Tag $GITHUB_REF_NAME does not match pyproject.toml version $project_version" + exit 1 + fi + - name: Build and verify package artifacts + run: uv run --locked --extra dev tox run -e build + - name: Extract release notes + run: | + uv run --locked --extra dev python scripts/gen_changelog.py \ + --current --strip header -o release-notes.md + if [ ! -s release-notes.md ]; then + echo "::error::No changelog content found for $GITHUB_REF_NAME" + exit 1 + fi + cat release-notes.md + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: ${{ github.ref_name }} + body_path: release-notes.md + files: | + dist/*.whl + dist/*.tar.gz + draft: false + prerelease: false + + # Publishing to PyPI is opt-in. To enable it: + # 1. Create a PyPI Trusted Publisher for this repository, pointing at + # workflow `release.yaml` and environment `pypi`. + # See https://docs.pypi.org/trusted-publishers/ + # 2. Create a GitHub environment named `pypi`. + # 3. Uncomment the job below. + # No API token is needed; authentication uses OIDC. + # + # publish: + # name: Publish to PyPI + # needs: release + # runs-on: ubuntu-latest + # timeout-minutes: 15 + # environment: pypi + # permissions: + # id-token: write + # steps: + # - uses: actions/checkout@v6 + # - uses: ./.github/actions/setup-python-uv + # with: + # python-version: ${{ env.PYTHON_VERSION }} + # - name: Build distributions + # run: uv run --locked --extra dev tox run -e build + # - name: Publish + # uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index d311a56..43d2d4d 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,8 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +.ruff_cache/ +.mypy_cache/ cover/ # Translations diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 848f1fa..4bea083 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,8 +40,24 @@ repos: - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks rev: v2.16.0 hooks: + # --preserve-quotes matters: ruamel follows YAML 1.2, where a bare 06:00 + # is a string, so without it the formatter strips "redundant" quotes that + # YAML 1.1 parsers (Ruby's Psych, used by Dependabot) need in order to + # read the value as a string instead of the integer 21600. - id: pretty-format-yaml - args: [--autofix, --indent, '2'] + args: [--autofix, --indent, '2', --preserve-quotes] + + # Runs after the formatter, because a bare HH:MM is a string to every + # Python YAML parser: check-yaml and JSON Schema validation both accept + # the value Dependabot rejects, so it needs a dedicated check. +- repo: local + hooks: + - id: check-yaml-sexagesimal + name: check for unquoted YAML clock values + entry: python scripts/check_yaml_sexagesimal.py + language: python + types: [yaml] + # Check for common spelling mistakes - repo: https://github.com/codespell-project/codespell rev: v2.4.2 @@ -58,6 +74,21 @@ repos: - id: ruff-format files: ^((project_name|tests|examples)/.+)?[^/]+\.(py|pyi)$ + # SPDX headers: insert missing ones, then verify REUSE compliance +- repo: local + hooks: + - id: reuse-annotate + name: add SPDX headers + entry: python scripts/add_spdx_headers.py + language: python + additional_dependencies: ['reuse>=6.2,<7'] + types: [python] + +- repo: https://github.com/fsfe/reuse-tool + rev: v6.2.0 + hooks: + - id: reuse + # Global file exclusions exclude: | diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29..825c32f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 325f0e9..bc8fc60 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,6 +56,23 @@ Build and smoke-test the package artifacts: uv run tox -e build ``` +Preview the generated changelog: + +```bash +uv run tox -e changelog +``` + +Prepare a release — bumps the version, regenerates the changelog, commits, +and tags: + +```bash +uv run tox -e release -- 0.1.0 +uv run tox -e release -- 0.1.0 --push +``` + +Pushing the tag triggers the `Release` workflow, which builds the +distributions and publishes a GitHub Release. + Build and smoke-test the Docker image: ```bash @@ -111,6 +128,18 @@ A good pull request should include: ## Release notes -Update `CHANGELOG.md` for changes that affect generated projects, supported -Python versions, dependency management, CI behavior, documentation publishing, -or the public template workflow. +`CHANGELOG.md` is generated from commit messages by git-cliff — do not edit it +by hand. What lands in it is decided by the Conventional Commits prefix on each +commit, so write commit subjects that read as release notes on their own, +especially for changes that affect generated projects, supported Python +versions, dependency management, CI behavior, documentation publishing, or the +public template workflow. + +Preview the result before opening a pull request: + +```bash +uv run tox -e changelog +``` + +The prefix-to-section mapping and the full release procedure are documented in +the "Commit, license, and release workflow" section of `README.md`. diff --git a/LICENSE b/LICENSE index fdddb29..6120396 100644 --- a/LICENSE +++ b/LICENSE @@ -1,24 +1,24 @@ -This is free and unencumbered software released into the public domain. +BSD 2-Clause License -Anyone is free to copy, modify, publish, use, compile, sell, or -distribute this software, either in source code form or as a compiled -binary, for any purpose, commercial or non-commercial, and by any -means. +Copyright (c) 2026 the Python Template contributors -In jurisdictions that recognize copyright laws, the author or authors -of this software dedicate any and all copyright interest in the -software to the public domain. We make this dedication for the benefit -of the public at large and to the detriment of our heirs and -successors. We intend this dedication to be an overt act of -relinquishment in perpetuity of all present and future rights to this -software under copyright law. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR -OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. -For more information, please refer to +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/LICENSES/BSD-2-Clause.txt b/LICENSES/BSD-2-Clause.txt new file mode 100644 index 0000000..6120396 --- /dev/null +++ b/LICENSES/BSD-2-Clause.txt @@ -0,0 +1,24 @@ +BSD 2-Clause License + +Copyright (c) 2026 the Python Template contributors + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 98bbc8d..9398106 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,150 @@ docker run --rm python-template:ci uv run python -c "import project_name" See `CONTRIBUTING.md` for the full local verification workflow and the generated-project smoke test. +## Commit, license, and release workflow + +Three pieces of automation depend on each other, in this order: commit +messages feed the changelog, license headers are added while you commit, and +the release command turns both into a tagged GitHub Release. + +### 1. Commit + +Install the git hooks once per clone. Without this, the hooks only run when you +invoke them by hand: + +```bash +uv run pre-commit install +``` + +From then on every `git commit` formats and lints the staged files, inserts +missing license headers, and verifies REUSE compliance. Run the same hooks +across the whole repository at any time: + +```bash +uv run pre-commit run --all-files +``` + +If a hook rewrites a file, the commit is aborted with the fix already applied +to your working tree — stage it and commit again: + +```bash +git add -A +git commit -m "feat(greeter): add localized greetings" +``` + +**Commit messages must follow the Conventional Commits format**, because +`cliff.toml` derives the changelog from the message prefix. Use +`type: subject` or `type(scope): subject`: + +| Prefix | Changelog section | +| ----------------------------- | ----------------- | +| `feat:` | Features | +| `fix:` | Bug Fixes | +| `perf:` | Performance | +| `refactor:` | Refactoring | +| `docs:` | Documentation | +| `test:` | Testing | +| `build:`, `ci:` | Build & CI | +| `chore:`, `style:`, `format:` | Chores | +| anything else | Other | + +Only the subject after the prefix reaches the changelog, so write the subject +as a standalone sentence. Merge commits and `chore(release):` commits are +skipped, which keeps release commits out of their own changelog. + +### 2. License headers + +Headers are appended automatically — you do not normally run anything. The +`add SPDX headers` hook calls `scripts/add_spdx_headers.py` on every staged +Python file, which shells out to `reuse annotate` and writes: + +```python +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause +``` + +Existing headers are left alone, and zero-byte files (such as empty +`__init__.py`) are skipped. The `reuse lint` hook then fails the commit if any +file still lacks copyright or license information. + +To annotate or check files outside a commit, drive the hooks directly: + +```bash +uv run pre-commit run reuse-annotate --files project_name/greeter.py +uv run pre-commit run reuse --all-files +``` + +`reuse` itself is installed only inside that hook's environment, not as a +project dependency, so calling the script directly needs the tool supplied. +Do that when you need a different year or copyright holder than the defaults: + +```bash +uv run --with 'reuse>=6.2,<7' python scripts/add_spdx_headers.py \ + --year 2027 --copyright "Your Name" project_name/greeter.py +``` + +Non-Python files are covered in bulk by `REUSE.toml` instead of an in-file +header, which is why config files, workflows, and Markdown carry no comment +block. Add new paths to the `path` list there rather than editing those files. +The license text itself lives in `LICENSES/BSD-2-Clause.txt`; REUSE requires +every identifier used anywhere in the repository to have a matching file in +that directory. When test fixtures or docs need to quote an SPDX tag as data, +wrap the region in `REUSE-IgnoreStart` / `REUSE-IgnoreEnd` comments so it is +not mistaken for a real annotation. + +### 3. Release and changelog + +Preview what the changelog would contain, without writing anything: + +```bash +uv run tox -e changelog # unreleased commits +uv run tox -e changelog -- --tag v0.1.0 # as it would look tagged v0.1.0 +uv run tox -e changelog -- -o CHANGELOG.md # regenerate the file in place +``` + +`CHANGELOG.md` is generated, never hand-edited. Fix a wrong entry by amending +the commit message it came from and regenerating. + +Then cut the release. Start with a dry run, which prints the planned steps and +touches nothing: + +```bash +uv run tox -e release -- 0.1.0 --dry-run +``` + +When it looks right, run it for real from a clean working tree: + +```bash +uv run tox -e release -- 0.1.0 # bump, changelog, commit, tag +uv run tox -e release -- 0.1.0 --push # ...and push the branch and tag +``` + +The release command refuses to run if the version is not `X.Y.Z`, if the tag +already exists, or if the working tree has uncommitted changes. Otherwise it: + +1. bumps `version` in `pyproject.toml` +1. runs `uv lock` and `uv lock --check`, so the lockfile matches the new + version and CI's `--locked` installs keep working +1. writes the new section into `CHANGELOG.md` — a full render for the first + release, prepended above the previous section afterwards +1. commits the three files as `chore(release): v0.1.0` +1. creates the annotated tag `v0.1.0` + +Add `--no-tag` to produce the commit without a tag. Nothing is pushed unless +you pass `--push`; otherwise push when ready: + +```bash +git push origin HEAD --follow-tags +``` + +Pushing a `v*` tag triggers the `Release` workflow, which verifies the tag +matches the `pyproject.toml` version, builds and smoke-tests the +distributions with `tox -e build`, extracts that version's changelog section +as the release notes, and publishes a GitHub Release with the wheel and sdist +attached. Publishing to PyPI is opt-in — see the commented `publish` job in +`.github/workflows/release.yaml` for the Trusted Publisher setup. + ## Documentation Documentation lives in `docs/` and is built with Sphinx. Manual pages are @@ -158,12 +302,15 @@ pull requests. | Path | Status | Purpose | | --------------- | -------- | ------------------------------------------------------------------------- | | `.github/` | Optional | GitHub Actions, Dependabot, and repository guidance. | +| `LICENSES/` | Required | Full text of every SPDX license used, as REUSE requires. | | `docs/` | Required | Sphinx documentation, including manual pages and generated API reference. | | `examples/` | Optional | Runnable examples for users and contributors. | | `project_name/` | Required | Source package. The bootstrap script renames this directory. | | `scripts/` | Optional | Repository automation scripts, including the bootstrap script. | | `tests/` | Required | Pytest test suite. Mirror the package structure where practical. | | `Dockerfile` | Optional | Container build for running the project example. | +| `REUSE.toml` | Optional | Bulk SPDX annotations for files that carry no in-file header. | +| `cliff.toml` | Optional | git-cliff changelog generation rules. | | `tox.ini` | Required | Local and CI task definitions. | ## Included tools @@ -177,4 +324,7 @@ pull requests. - [MyST Parser](https://myst-parser.readthedocs.io/) for Markdown in Sphinx - [Read the Docs](https://readthedocs.org/) for hosted documentation - [pip-audit](https://pypi.org/project/pip-audit/) for dependency vulnerability checks +- [git-cliff](https://git-cliff.org/) for changelog generation +- [REUSE](https://reuse.software/) for SPDX license headers and compliance +- [pre-commit](https://pre-commit.com/) for the commit-time hook suite - [GitHub Actions](https://docs.github.com/en/actions) for CI automation diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 0000000..4bc01d2 --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,37 @@ +version = 1 + +[[annotations]] +path = [ + ".claude/**", + ".devcontainer/**", + ".env", + ".github/**", + ".gitignore", + ".pre-commit-config.yaml", + ".pylintrc", + ".readthedocs.yaml", + "AGENTS.md", + "CHANGELOG.md", + "CLAUDE.md", + "CONTRIBUTING.md", + "Dockerfile", + "LICENSES/**", + "README.md", + "cliff.toml", + "docs/*.md", + "pyproject.toml", + "tox.ini", + "uv.lock", +] +precedence = "aggregate" +SPDX-FileCopyrightText = "2026 the Python Template contributors" +SPDX-License-Identifier = "BSD-2-Clause" + +# The plan and spec documents quote SPDX tags inside fenced code blocks. +# `override` makes reuse use these values instead of parsing those quotes +# as real annotations. +[[annotations]] +path = ["docs/superpowers/**"] +precedence = "override" +SPDX-FileCopyrightText = "2026 the Python Template contributors" +SPDX-License-Identifier = "BSD-2-Clause" diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..def11d4 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,63 @@ +# git-cliff configuration. Produces, per release: +# +# ## VERSION - DATE +# +# ### Group +# +# - [shorthash] Subject +# +# Regenerate with `uv run tox run -e changelog -- -o CHANGELOG.md`. +# +# --prepend caveat: git-cliff only recognises the `# Changelog` header +# above when it is followed by a blank line. CHANGELOG.md is committed as +# a bare `# Changelog\n` (a pre-commit hook strips the blank line), so +# `--prepend` against it does not match the header and duplicates +# `# Changelog` at the bottom of the file instead of leaving it alone. Use +# `-o` for a full regeneration, not `--prepend`, against the committed +# file. `--prepend` also requires a commit range (or `-u`/`-l`), so the +# first release must use `-o` regardless. + +[changelog] +header = "# Changelog\n\n" +body = """ +{% if version %}\ +## {{ version | trim_start_matches(pat="v") }} - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ +## Unreleased +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | striptags | trim | upper_first }} + +{% for commit in commits %}\ +- [{{ commit.id | truncate(length=7, end="") }}] {{ commit.message | upper_first }} +{% endfor %}\ +{% endfor %}\n +""" +trim = true +footer = "" + +[git] +conventional_commits = true +filter_unconventional = false +split_commits = false +protect_breaking_commits = false +filter_commits = false +tag_pattern = "v[0-9]*" +topo_order = false +sort_commits = "oldest" +# Group names carry `` prefixes purely to force ordering; the +# template strips them with `striptags`. Without them `group_by` sorts +# alphabetically. +commit_parsers = [ + { message = "^chore\\(release\\)", skip = true }, + { message = "^Merge ", skip = true }, + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^docs", group = "Documentation" }, + { message = "^test", group = "Testing" }, + { message = "^(build|ci)", group = "Build & CI" }, + { message = "^(chore|style|format)", group = "Chores" }, + { message = ".*", group = "Other" }, +] diff --git a/docs/conf.py b/docs/conf.py index 9eaece9..de5b39e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + """Sphinx configuration for the project documentation.""" from __future__ import annotations @@ -26,7 +30,15 @@ ".md": "markdown", ".rst": "restructuredtext", } -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +# `superpowers/` holds development plans and design notes, not published +# documentation. They are not in any toctree and quote code that the +# highlighter cannot lex, both of which are errors under `sphinx -W`. +exclude_patterns = [ + "_build", + "Thumbs.db", + ".DS_Store", + "superpowers/**", +] html_theme = "furo" html_title = "project_name documentation" diff --git a/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md b/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md new file mode 100644 index 0000000..2888b60 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md @@ -0,0 +1,1704 @@ +# Release Automation, Changelog Generation, and SPDX Headers Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add tag-triggered GitHub Release publishing, git-cliff changelog generation, and auto-inserted SPDX/REUSE headers to this Python template, with all three surviving `scripts/bootstrap_template.py`. + +**Architecture:** Three independent layers. The SPDX layer is two pre-commit hooks (a local fixer, then the upstream `reuse` linter) plus `REUSE.toml` for bulk-annotating non-Python files. The changelog layer is `cliff.toml` plus a thin `gen_changelog.py` wrapper. The release layer is `release.py` (version bump, lockfile sync, changelog prepend, commit, tag) driven locally via tox and consumed in CI by a tag-triggered workflow. A fourth task wires all of it into the template bootstrap script. + +**Tech Stack:** Python 3.10+, uv, tox (`uv-venv-lock-runner`), pre-commit, `git-cliff>=2.13,<3`, `reuse>=6.2,<7`, GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-07-29-release-changelog-spdx-design.md` + +## Global Constraints + +Every task's requirements implicitly include this section. + +- Copyright holder string is exactly `the Python Template contributors`. License is exactly `BSD-2-Clause`. +- Ruff `line-length = 80`, `target-version = "py310"`. Ruff lint selects `D` (pydocstyle, Google convention) — **every module, function, and class outside `tests/` needs a docstring**. `tests/**/*.py` has `D` ignored. +- Ruff format uses `quote-style = "double"`. +- New scripts go in `scripts/`, are Python (not shell), start with `#!/usr/bin/env python3`, use `from __future__ import annotations`, and end with `raise SystemExit(main())`. Follow the existing style of `scripts/validate_distribution.py` and `scripts/bootstrap_template.py`. +- **Every new script in `scripts/` must be committed executable:** `chmod +x scripts/.py` before `git add`. The `check-shebang-scripts-are-executable` pre-commit hook fails a shebang'd file that is not executable, and it reads the *git index* mode — so `git add --chmod=+x scripts/.py` is the reliable form. All three existing `scripts/*.py` are mode `100755`. (`examples/say_hi.py` is `100644` because it has no shebang; do not add one.) +- All new `.py` files must carry the SPDX header shown in Task 1 verbatim, placed after any shebang and before the module docstring. +- `requires-python = ">=3.10,<4"`. `reuse` requires >=3.10, which matches. +- CI invokes tox only as `tox run -e `. **Do not add `changelog` or `release` to `env_list`** — bare `uv run tox` would otherwise fire a release attempt. +- Commit messages are conventional (`feat:`, `fix:`, `docs:`, `build:`, `ci:`, `test:`, `chore:`), because `cliff.toml` parses them into changelog groups. +- Work on branch `feat/release-changelog-spdx`, which already exists and holds the spec. +- pre-commit is **not** installed as a git hook in this clone. Hooks do not run automatically on commit; run them explicitly with `uv run --extra dev pre-commit run --all-files` when a task says to. + +______________________________________________________________________ + +### Task 1: SPDX / REUSE layer + +**Files:** + +- Create: `LICENSES/BSD-2-Clause.txt` +- Create: `REUSE.toml` +- Create: `scripts/add_spdx_headers.py` +- Test: `tests/test_add_spdx_headers.py` +- Modify: `.pre-commit-config.yaml` (append two repos before the trailing `exclude:` block) +- Modify: `.gitignore:51` (add two cache entries) +- Modify: all 12 non-empty tracked `.py` files (headers inserted by the tool, not by hand) + +**Interfaces:** + +- Consumes: nothing. + +- Produces: `scripts/add_spdx_headers.py` exposing `annotatable(paths: list[str]) -> list[str]` and `build_command(paths: list[str], *, year: str, copyright_holder: str, license_id: str) -> list[str]`. `REUSE.toml` containing the literal string `the Python Template contributors` (Task 5 rewrites it). The header format Task 5's tests assert against. + +- [ ] **Step 1: Create the LICENSES directory** + +REUSE requires the license text under `LICENSES/`. A root `LICENSE` is also kept — GitHub's license detection reads it, and `reuse lint` ignores it. + +**Do not copy the existing root `LICENSE`.** It contains Unlicense text while `pyproject.toml` declares `license = "BSD-2-Clause"` — a pre-existing contradiction in this repository. The project owner ruled that **BSD-2-Clause is correct and the root `LICENSE` file was the mistake**. Write canonical BSD-2-Clause text, with the copyright line `Copyright (c) 2026 the Python Template contributors`, to *both* `LICENSE` and `LICENSES/BSD-2-Clause.txt` with identical content. + +```bash +mkdir -p LICENSES +# write BSD-2-Clause text to LICENSE, then: +cp LICENSE LICENSES/BSD-2-Clause.txt +``` + +- [ ] **Step 2: Write the failing test for `add_spdx_headers`** + +Create `tests/test_add_spdx_headers.py`: + +```python +import sys + +from scripts.add_spdx_headers import annotatable, build_command + + +def test_annotatable_keeps_non_empty_files(tmp_path): + populated = tmp_path / "populated.py" + populated.write_text("x = 1\n", encoding="utf-8") + + assert annotatable([str(populated)]) == [str(populated)] + + +def test_annotatable_skips_zero_byte_files(tmp_path): + empty = tmp_path / "__init__.py" + empty.touch() + + assert annotatable([str(empty)]) == [] + + +def test_annotatable_skips_missing_paths(tmp_path): + assert annotatable([str(tmp_path / "absent.py")]) == [] + + +def test_annotatable_skips_directories(tmp_path): + assert annotatable([str(tmp_path)]) == [] + + +def test_build_command_uses_reuse_annotate_with_skip_flags(): + command = build_command( + ["pkg/mod.py"], + year="2026", + copyright_holder="the Python Template contributors", + license_id="BSD-2-Clause", + ) + + assert command[:4] == [sys.executable, "-m", "reuse", "annotate"] + assert "--skip-existing" in command + assert "--merge-copyrights" in command + assert "--skip-unrecognised" in command + assert command[-1] == "pkg/mod.py" + + +def test_build_command_passes_copyright_metadata(): + command = build_command( + ["pkg/mod.py"], + year="2026", + copyright_holder="the Acme contributors", + license_id="BSD-2-Clause", + ) + + assert command[command.index("--year") + 1] == "2026" + assert command[command.index("--copyright") + 1] == "the Acme contributors" + assert command[command.index("--license") + 1] == "BSD-2-Clause" +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `uv run --extra dev pytest tests/test_add_spdx_headers.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'scripts.add_spdx_headers'` + +- [ ] **Step 4: Write `scripts/add_spdx_headers.py`** + +`--skip-existing` makes the hook idempotent; `--skip-unrecognised` stops it from failing on a file type `reuse` has no comment style for. Zero-byte files are filtered out because `reuse lint` ignores them, so annotating `tests/__init__.py` would add content to an intentionally empty file for no compliance gain. + +```python +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +"""Insert SPDX licensing headers into Python files that lack them.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from datetime import date +from pathlib import Path + +COPYRIGHT_HOLDER = "the Python Template contributors" +LICENSE_ID = "BSD-2-Clause" + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser() + parser.add_argument( + "paths", + nargs="*", + help="Files to annotate. Supplied by pre-commit.", + ) + parser.add_argument( + "--year", + default=str(date.today().year), + help="Copyright year to write. Defaults to the current year.", + ) + parser.add_argument( + "--copyright", + default=COPYRIGHT_HOLDER, + dest="copyright_holder", + help="Copyright holder to write into the header.", + ) + parser.add_argument( + "--license", + default=LICENSE_ID, + dest="license_id", + help="SPDX license identifier to write into the header.", + ) + return parser.parse_args() + + +def annotatable(paths: list[str]) -> list[str]: + """Return the paths reuse can annotate. + + Zero-byte files are skipped because ``reuse lint`` ignores them, so + annotating them would add content to intentionally empty files without + improving compliance. + + Args: + paths: Candidate file paths. + + Returns: + The subset of paths that are non-empty regular files. + """ + keep = [] + for raw in paths: + path = Path(raw) + if path.is_file() and path.stat().st_size > 0: + keep.append(raw) + return keep + + +def build_command( + paths: list[str], + *, + year: str, + copyright_holder: str, + license_id: str, +) -> list[str]: + """Build the ``reuse annotate`` command for the given paths. + + Args: + paths: Files to annotate. + year: Copyright year to write. + copyright_holder: Copyright holder to write. + license_id: SPDX license identifier to write. + + Returns: + The command as an argument list. + """ + return [ + sys.executable, + "-m", + "reuse", + "annotate", + "--skip-existing", + "--merge-copyrights", + "--skip-unrecognised", + "--year", + year, + "--copyright", + copyright_holder, + "--license", + license_id, + *paths, + ] + + +def main() -> int: + """Annotate the requested files and return the reuse exit status.""" + args = parse_args() + paths = annotatable(args.paths) + if not paths: + return 0 + + command = build_command( + paths, + year=args.year, + copyright_holder=args.copyright_holder, + license_id=args.license_id, + ) + return subprocess.run(command, check=False).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `uv run --extra dev pytest tests/test_add_spdx_headers.py -v` +Expected: PASS, 6 tests. + +- [ ] **Step 6: Create `REUSE.toml`** + +This covers every tracked non-Python file. `reuse lint` ignores the root `LICENSE`, `LICENSES/**`, `REUSE.toml` itself, and anything gitignored, so none of those are listed. `AGENTS.md`, `CLAUDE.md`, and `.claude/**` **are** listed: they are untracked but not gitignored, and `reuse lint` only skips VCS-ignored files. `docs/*.md` is used instead of `docs/**` so that `docs/conf.py` is covered by its inline header alone. + +`docs/superpowers/**` needs a **second** `[[annotations]]` block with `precedence = "override"`. The spec and plan documents quote `SPDX-License-Identifier:` inside fenced code blocks, and `reuse` parses those quotes as real annotations, producing "invalid SPDX License Expression" errors. `override` tells reuse to use the `REUSE.toml` values and ignore anything found inside those files. Verified against reuse 6.2.0: it takes the error count from 3 to 0. + +```toml +[[annotations]] +path = ["docs/superpowers/**"] +precedence = "override" +SPDX-FileCopyrightText = "2026 the Python Template contributors" +SPDX-License-Identifier = "BSD-2-Clause" +``` + +```toml +version = 1 + +[[annotations]] +path = [ + ".claude/**", + ".devcontainer/**", + ".env", + ".github/**", + ".gitignore", + ".pre-commit-config.yaml", + ".pylintrc", + ".readthedocs.yaml", + "AGENTS.md", + "CHANGELOG.md", + "CLAUDE.md", + "CONTRIBUTING.md", + "Dockerfile", + "LICENSES/**", + "README.md", + "cliff.toml", + "docs/*.md", + "docs/superpowers/**", + "pyproject.toml", + "tox.ini", + "uv.lock", +] +precedence = "aggregate" +SPDX-FileCopyrightText = "2026 the Python Template contributors" +SPDX-License-Identifier = "BSD-2-Clause" +``` + +- [ ] **Step 7: Add the missing cache entries to `.gitignore`** + +`.ruff_cache/` and `.mypy_cache/` are currently not ignored, so `reuse lint` would fail on their contents. Insert both after `.pytest_cache/` on line 51, keeping the "Unit test / coverage reports" block alphabetically loose as it already is: + +``` +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +cover/ +``` + +- [ ] **Step 8: Register both pre-commit hooks** + +Append to `.pre-commit-config.yaml` after the `ruff-pre-commit` repo block and before the trailing `# Global file exclusions` / `exclude:` block. Order matters: the fixer runs first, the linter verifies. + +```yaml + # SPDX headers: insert missing ones, then verify REUSE compliance +- repo: local + hooks: + - id: reuse-annotate + name: add SPDX headers + entry: python scripts/add_spdx_headers.py + language: python + additional_dependencies: ['reuse>=6.2,<7'] + types: [python] + +- repo: https://github.com/fsfe/reuse-tool + rev: v6.2.0 + hooks: + - id: reuse +``` + +- [ ] **Step 9: Insert headers into every existing Python file** + +Let the hook do it — do not hand-write headers. The first run reports failure because it modifies files; that is normal pre-commit behavior for a fixing hook. + +Run: `uv run --extra dev pre-commit run reuse-annotate --all-files` +Expected: FAIL, reporting "files were modified by this hook". + +Then confirm the format on a file that has a shebang and one that does not: + +Run: `head -6 scripts/bootstrap_template.py project_name/greeter.py` + +Expected — shebang preserved above the header, `#` separator line present, blank line before the docstring: + +```python +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +"""Bootstrap a repository created from this template.""" +``` + +Confirm `tests/__init__.py` is still 0 bytes: + +Run: `wc -c tests/__init__.py` +Expected: `0 tests/__init__.py` + +- [ ] **Step 10: Verify full REUSE compliance and that nothing else broke** + +Run: `uv run --extra dev pre-commit run --all-files` +Expected: PASS on every hook. If `reuse` reports missing licensing information, add the offending path to `REUSE.toml` and re-run. + +Run: `uv run --extra dev pytest tests/ -q` +Expected: PASS. The inserted headers are comments and must not change behavior. + +Run: `uv run --extra dev tox run -e type` +Expected: PASS. + +- [ ] **Step 11: Commit** + +```bash +chmod +x scripts/add_spdx_headers.py +git add --chmod=+x scripts/add_spdx_headers.py +git add LICENSES REUSE.toml tests/test_add_spdx_headers.py \ + .pre-commit-config.yaml .gitignore \ + project_name tests examples scripts docs/conf.py +git commit -m "feat(license): auto-insert and verify SPDX headers" +``` + +Before committing, confirm no unrelated file-mode changes are staged — `git diff --cached --summary` should show `mode change` only for files this task intentionally touched. + +______________________________________________________________________ + +### Task 2: Changelog layer + +**Files:** + +- Create: `cliff.toml` +- Create: `scripts/gen_changelog.py` +- Modify: `CHANGELOG.md` (seed; currently 0 bytes) +- Modify: `pyproject.toml` (add `git-cliff` to the `dev` extra) +- Modify: `tox.ini` (add `[testenv:changelog]`, **not** to `env_list`) +- Modify: `uv.lock` (regenerated) +- Modify: `README.md`, `CONTRIBUTING.md` (document the env) + +**Interfaces:** + +- Consumes: the SPDX header format from Task 1. + +- Produces: `cliff.toml` at the repo root, consumed by Task 3's `release.py` and Task 4's workflow via `--config cliff.toml`. `scripts/gen_changelog.py` passing `sys.argv[1:]` through to `git-cliff`. + +- [ ] **Step 1: Add `git-cliff` to the dev extra** + +It ships platform wheels on PyPI that bundle the Rust binary, so it resolves like any other dependency — no unpinned `uvx` fetch. Add to `[project.optional-dependencies] dev` in `pyproject.toml`, keeping the existing loose alphabetical-ish grouping (place it after `build` and before `hatchling`): + +```toml + "git-cliff>=2.13,<3", +``` + +Then relock: + +```bash +uv lock +``` + +- [ ] **Step 2: Create `cliff.toml`** + +This exact config was validated against this repository's real history — group ordering, the `striptags` prefix trick, and unconventional-commit retention all confirmed working with git-cliff 2.13.1. The trailing `\` on `{% endif %}` suppresses a duplicate blank line under the version heading. + +```toml +# git-cliff configuration. Produces, per release: +# +# ## VERSION - DATE +# +# ### Group +# +# - [shorthash] Subject +# +# Regenerate with `uv run tox run -e changelog -- -o CHANGELOG.md`. + +[changelog] +header = "# Changelog\n\n" +body = """ +{% if version %}\ +## {{ version | trim_start_matches(pat="v") }} - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ +## Unreleased +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | striptags | trim | upper_first }} + +{% for commit in commits %}\ +- [{{ commit.id | truncate(length=7, end="") }}] {{ commit.message | upper_first }} +{% endfor %}\ +{% endfor %}\n +""" +trim = true +footer = "" + +[git] +conventional_commits = true +filter_unconventional = false +split_commits = false +protect_breaking_commits = false +filter_commits = false +tag_pattern = "v[0-9]*" +topo_order = false +sort_commits = "oldest" +# Group names carry `` prefixes purely to force ordering; the +# template strips them with `striptags`. Without them `group_by` sorts +# alphabetically. +commit_parsers = [ + { message = "^chore\\(release\\)", skip = true }, + { message = "^Merge ", skip = true }, + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^docs", group = "Documentation" }, + { message = "^test", group = "Testing" }, + { message = "^(build|ci)", group = "Build & CI" }, + { message = "^(chore|style|format)", group = "Chores" }, + { message = ".*", group = "Other" }, +] +``` + +- [ ] **Step 3: Seed `CHANGELOG.md`** + +`git-cliff --prepend` inserts only the rendered body, never the `[changelog] header`. The file is currently 0 bytes, so without seeding the first release would produce a changelog with no `# Changelog` title. + +```bash +printf '# Changelog\n\n' > CHANGELOG.md +``` + +The `end-of-file-fixer` pre-commit hook will trim this to a single trailing newline (`# Changelog\n`), and that is fine and expected — **do not fight the hook.** The reason it is safe is behavioral, and was verified against git-cliff 2.13.1: + +- git-cliff recognises its configured header only when the file's `# Changelog` is followed by a blank line. Against the trimmed one-newline seed, `--prepend` does not match the header and instead emits a **duplicate** `# Changelog` at the bottom of the file. +- That state is never reached, because `generate_changelog` in Task 3 uses `-o` (full regeneration, which emits the header itself) whenever there is no previous tag — i.e. for the first release. The result is `# Changelog\n\n## 0.1.0 …`, whose header *is* followed by a blank line. +- Every subsequent release therefore prepends correctly, inserting the new section between the header and the previous release's section. + +Do not add a prose preamble under the heading to force the blank line: `--prepend` inserts immediately after the header, so the preamble would sink below the newest release section on the first prepend. Header plus version sections only. + +- [ ] **Step 4: Write `scripts/gen_changelog.py`** + +```python +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +"""Regenerate CHANGELOG.md from git history using the rules in cliff.toml.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +CONFIG = Path(__file__).resolve().parents[1] / "cliff.toml" + + +def build_command(argv: list[str]) -> list[str]: + """Build the git-cliff command for the given passthrough arguments. + + Args: + argv: Arguments forwarded verbatim to git-cliff. + + Returns: + The command as an argument list. + """ + return [ + sys.executable, + "-m", + "git_cliff", + "--config", + str(CONFIG), + *argv, + ] + + +def main() -> int: + """Run git-cliff and return its exit status.""" + return subprocess.run(build_command(sys.argv[1:]), check=False).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +- [ ] **Step 5: Verify the module entry point, and fall back if absent** + +The `git-cliff` wheel may expose only a console script rather than an importable `git_cliff` module. + +Run: `uv run --extra dev python -m git_cliff --version` +Expected: `git-cliff 2.13.x` + +If that fails with `No module named git_cliff`, change `build_command` to invoke the console script from the same environment instead, and keep everything else identical: + +```python +def build_command(argv: list[str]) -> list[str]: + """Build the git-cliff command for the given passthrough arguments. + + Args: + argv: Arguments forwarded verbatim to git-cliff. + + Returns: + The command as an argument list. + """ + executable = Path(sys.executable).parent / "git-cliff" + return [str(executable), "--config", str(CONFIG), *argv] +``` + +- [ ] **Step 6: Add the tox env** + +Add to `tox.ini` after `[testenv:build]`. Do **not** add `changelog` to `env_list` — bare `uv run tox` must not regenerate the changelog. + +```ini +[testenv:changelog] +description = generate the changelog from git history +runner = uv-venv-lock-runner +extras = dev +allowlist_externals = + git +commands = + {envpython} scripts/gen_changelog.py {posargs} +``` + +- [ ] **Step 7: Verify the changelog renders correctly** + +Run: `uv run --extra dev tox run -e changelog -- --tag v0.1.0` + +Expected: grouped output on stdout, with `### Features` before `### Bug Fixes` before `### Refactoring`, exactly one blank line between the `## 0.1.0 - ` heading and the first `###` heading, and early non-conventional commits collected under `### Other`. `CHANGELOG.md` must be unchanged (no `-o` was passed). + +Run: `git diff --stat CHANGELOG.md` +Expected: no output. + +- [ ] **Step 8: Document the env** + +In `README.md`, after the "Build and smoke-test the package artifacts" block (around line 113), and in `CONTRIBUTING.md` after its matching block (around line 56), add: + +````markdown +Preview the generated changelog: + +```bash +uv run tox -e changelog +``` +```` + +Also add a row to the `README.md` repository-layout table (around line 167), matching the existing column alignment: + +``` +| `cliff.toml` | Optional | git-cliff changelog generation rules. | +``` + +And add to the README "Included tools" list: + +```markdown +- [git-cliff](https://git-cliff.org/) for changelog generation +``` + +- [ ] **Step 9: Verify and commit** + +Run: `uv run --extra dev pre-commit run --all-files` +Expected: PASS. `cliff.toml` and `CHANGELOG.md` are both covered by `REUSE.toml`, so `reuse` must stay green. + +```bash +chmod +x scripts/gen_changelog.py +git add --chmod=+x scripts/gen_changelog.py +git add cliff.toml CHANGELOG.md pyproject.toml \ + uv.lock tox.ini README.md CONTRIBUTING.md +git commit -m "feat(changelog): generate CHANGELOG.md with git-cliff" +``` + +______________________________________________________________________ + +### Task 3: Release script + +**Files:** + +- Create: `scripts/release.py` +- Test: `tests/test_release.py` +- Modify: `tox.ini` (add `[testenv:release]`, **not** to `env_list`) +- Modify: `README.md`, `CONTRIBUTING.md` + +**Interfaces:** + +- Consumes: `cliff.toml` from Task 2, invoked as `git-cliff --config cliff.toml --tag --prepend CHANGELOG.md`. + +- Produces: `scripts/release.py` exposing `validate_version(version: str) -> str`, `tag_exists(tag: str, *, runner: Runner = run) -> bool`, `working_tree_dirty(*, runner: Runner = run) -> bool`, `previous_tag(*, runner: Runner = run) -> str | None`, `bump_pyproject(path: Path, version: str) -> None`, `sync_lockfile(*, runner: Runner = run) -> None`, `changelog_range(prev: str | None) -> list[str]`, and `generate_changelog(tag: str, prev: str | None, *, runner: Runner = run) -> None`. The commit subject format `chore(release): vX.Y.Z`, which `cliff.toml` skips via `^chore\(release\)` and Task 4's workflow relies on. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_release.py`. The `runner` seam keeps tests fast and offline — `sync_lockfile` is asserted on the commands it issues rather than by actually resolving dependencies. The git guards run against a real throwaway repo because that is cheap and catches real quoting bugs. + +```python +import subprocess + +import pytest + +from scripts.release import ( + bump_pyproject, + changelog_range, + generate_changelog, + previous_tag, + sync_lockfile, + tag_exists, + validate_version, + working_tree_dirty, +) + +PYPROJECT = """[project] +name = "project_name" +version = "0.0.0" +description = "A simple template project." +""" + + +class FakeRunner: + """Records commands and replays canned results.""" + + def __init__(self, results=None): + self.commands = [] + self.results = results or {} + + def __call__(self, command, *, check=True, capture=False): + self.commands.append(command) + key = " ".join(command) + result = self.results.get(key) + if result is None: + return subprocess.CompletedProcess(command, 0, "", "") + return result + + +def git_repo(tmp_path): + """Create a throwaway git repo with one commit.""" + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + (tmp_path / "file.txt").write_text("hello\n", encoding="utf-8") + subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) + subprocess.run( + ["git", "-c", "user.email=a@b", "-c", "user.name=a", + "commit", "-qm", "init"], + cwd=tmp_path, + check=True, + ) + return tmp_path + + +@pytest.mark.parametrize("version", ["0.1.0", "1.2.3", "10.0.1", "1.0.0-rc1"]) +def test_validate_version_accepts_valid_versions(version): + assert validate_version(version) == version + + +@pytest.mark.parametrize("version", ["1.2", "v1.2.3", "abc", "", "1.2.3-"]) +def test_validate_version_rejects_invalid_versions(version): + with pytest.raises(ValueError): + validate_version(version) + + +def test_bump_pyproject_replaces_version(tmp_path): + path = tmp_path / "pyproject.toml" + path.write_text(PYPROJECT, encoding="utf-8") + + bump_pyproject(path, "0.1.0") + + assert 'version = "0.1.0"' in path.read_text(encoding="utf-8") + assert 'version = "0.0.0"' not in path.read_text(encoding="utf-8") + + +def test_bump_pyproject_leaves_other_metadata_alone(tmp_path): + path = tmp_path / "pyproject.toml" + path.write_text(PYPROJECT, encoding="utf-8") + + bump_pyproject(path, "0.1.0") + + content = path.read_text(encoding="utf-8") + assert 'name = "project_name"' in content + assert 'description = "A simple template project."' in content + + +def test_bump_pyproject_raises_without_a_version_line(tmp_path): + path = tmp_path / "pyproject.toml" + path.write_text('[project]\nname = "x"\n', encoding="utf-8") + + with pytest.raises(ValueError): + bump_pyproject(path, "0.1.0") + + +def test_tag_exists_is_false_for_unknown_tag(tmp_path, monkeypatch): + monkeypatch.chdir(git_repo(tmp_path)) + + assert tag_exists("v9.9.9") is False + + +def test_tag_exists_is_true_for_created_tag(tmp_path, monkeypatch): + repo = git_repo(tmp_path) + subprocess.run(["git", "tag", "-a", "v0.1.0", "-m", "v0.1.0"], + cwd=repo, check=True) + monkeypatch.chdir(repo) + + assert tag_exists("v0.1.0") is True + + +def test_working_tree_dirty_is_false_when_clean(tmp_path, monkeypatch): + monkeypatch.chdir(git_repo(tmp_path)) + + assert working_tree_dirty() is False + + +def test_working_tree_dirty_is_true_with_modifications(tmp_path, monkeypatch): + repo = git_repo(tmp_path) + (repo / "file.txt").write_text("changed\n", encoding="utf-8") + monkeypatch.chdir(repo) + + assert working_tree_dirty() is True + + +def test_previous_tag_is_none_without_tags(tmp_path, monkeypatch): + monkeypatch.chdir(git_repo(tmp_path)) + + assert previous_tag() is None + + +def test_previous_tag_returns_most_recent_reachable_tag(tmp_path, monkeypatch): + repo = git_repo(tmp_path) + subprocess.run(["git", "tag", "-a", "v0.1.0", "-m", "v0.1.0"], + cwd=repo, check=True) + monkeypatch.chdir(repo) + + assert previous_tag() == "v0.1.0" + + +def test_sync_lockfile_locks_then_verifies(): + runner = FakeRunner() + + sync_lockfile(runner=runner) + + assert runner.commands == [["uv", "lock"], ["uv", "lock", "--check"]] + + +def test_changelog_range_is_empty_without_a_previous_tag(): + assert changelog_range(None) == [] + + +def test_changelog_range_spans_from_previous_tag_to_head(): + assert changelog_range("v0.1.0") == ["v0.1.0..HEAD"] + + +def test_generate_changelog_regenerates_when_there_is_no_previous_tag(): + runner = FakeRunner() + + generate_changelog("v0.1.0", None, runner=runner) + + command = runner.commands[0] + assert "-o" in command + assert "--prepend" not in command + assert command[command.index("-o") + 1] == "CHANGELOG.md" + + +def test_generate_changelog_prepends_when_a_previous_tag_exists(): + runner = FakeRunner() + + generate_changelog("v0.1.0", "v0.0.9", runner=runner) + + command = runner.commands[0] + assert "--prepend" in command + assert "-o" not in command + assert "v0.0.9..HEAD" in command +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `uv run --extra dev pytest tests/test_release.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'scripts.release'` + +- [ ] **Step 3: Write `scripts/release.py`** + +`sync_lockfile` is the piece the reference repo's `release.sh` is missing. `uv.lock` pins the project's own version, and CI runs `uv run --locked`, so a release that bumps only `pyproject.toml` breaks every workflow on the release commit. + +```python +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +"""Prepare a release: bump the version, changelog, commit, and tag. + +The release workflow at ``.github/workflows/release.yaml`` picks up the +pushed tag and publishes a GitHub Release with the matching CHANGELOG +section. + +Usage: + scripts/release.py X.Y.Z # bump + commit + tag (no push) + scripts/release.py X.Y.Z --push # also push branch and tag + scripts/release.py X.Y.Z --dry-run # show what would happen + scripts/release.py X.Y.Z --no-tag # commit only, skip the tag +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path +from typing import Callable + +REPO_ROOT = Path(__file__).resolve().parents[1] +CONFIG = REPO_ROOT / "cliff.toml" +VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+(?:[.-][0-9A-Za-z.-]+)?$") +VERSION_LINE = re.compile(r'^version\s*=\s*"[^"]*"', re.MULTILINE) + +Runner = Callable[..., subprocess.CompletedProcess] + + +def run( + command: list[str], + *, + check: bool = True, + capture: bool = False, +) -> subprocess.CompletedProcess: + """Run a command, optionally capturing its output. + + Args: + command: The command and its arguments. + check: Raise on a non-zero exit status. + capture: Capture stdout and stderr as text. + + Returns: + The completed process. + """ + return subprocess.run( + command, + check=check, + capture_output=capture, + text=True, + ) + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser() + parser.add_argument("version", help="Release version, as X.Y.Z.") + parser.add_argument( + "--push", + action="store_true", + help="Push the release commit and tag to origin.", + ) + parser.add_argument( + "--no-tag", + action="store_true", + help="Create the release commit without an annotated tag.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the planned steps without modifying anything.", + ) + return parser.parse_args() + + +def validate_version(version: str) -> str: + """Validate a release version string. + + Args: + version: Candidate version. + + Returns: + The version unchanged. + + Raises: + ValueError: If the version is not of the form X.Y.Z. + """ + if not VERSION_PATTERN.fullmatch(version): + raise ValueError( + f"'{version}' is not a valid version (expected X.Y.Z)." + ) + return version + + +def tag_exists(tag: str, *, runner: Runner = run) -> bool: + """Return whether a git tag already exists. + + Args: + tag: Tag name to look for. + runner: Command runner, injectable for tests. + + Returns: + True if the tag resolves. + """ + result = runner( + ["git", "rev-parse", "--verify", f"refs/tags/{tag}"], + check=False, + capture=True, + ) + return result.returncode == 0 + + +def working_tree_dirty(*, runner: Runner = run) -> bool: + """Return whether the working tree has uncommitted changes. + + Args: + runner: Command runner, injectable for tests. + + Returns: + True if tracked files differ from HEAD. + """ + result = runner( + ["git", "status", "--porcelain", "--untracked-files=no"], + capture=True, + ) + return bool(result.stdout.strip()) + + +def previous_tag(*, runner: Runner = run) -> str | None: + """Return the most recent version tag reachable from HEAD. + + Tags on unrelated branches are skipped automatically. + + Args: + runner: Command runner, injectable for tests. + + Returns: + The tag name, or None when no version tag is reachable. + """ + result = runner( + [ + "git", + "describe", + "--tags", + "--abbrev=0", + "--match=v[0-9]*", + "HEAD", + ], + check=False, + capture=True, + ) + if result.returncode != 0: + return None + return result.stdout.strip() or None + + +def changelog_range(prev: str | None) -> list[str]: + """Return the git-cliff commit range arguments. + + Args: + prev: The previous release tag, if any. + + Returns: + A single-element range list, or an empty list for full history. + """ + if prev is None: + return [] + return [f"{prev}..HEAD"] + + +def bump_pyproject(path: Path, version: str) -> None: + """Rewrite the project version in pyproject.toml. + + Args: + path: Path to pyproject.toml. + version: New version string. + + Raises: + ValueError: If no version line is present. + """ + content = path.read_text(encoding="utf-8") + updated, count = VERSION_LINE.subn( + f'version = "{version}"', + content, + count=1, + ) + if count == 0: + raise ValueError(f"No version line found in {path}.") + path.write_text(updated, encoding="utf-8") + + +def sync_lockfile(*, runner: Runner = run) -> None: + """Refresh uv.lock for the new version and verify it is in sync. + + uv.lock pins the project's own version, and CI runs with ``--locked``, + so the lockfile must be regenerated alongside pyproject.toml. + + Args: + runner: Command runner, injectable for tests. + """ + runner(["uv", "lock"]) + runner(["uv", "lock", "--check"]) + + +def generate_changelog( + tag: str, + prev: str | None, + *, + runner: Runner = run, +) -> None: + """Write the new release section into CHANGELOG.md. + + With no previous tag the file is regenerated with ``-o``, because + git-cliff refuses ``--prepend`` unless a range, ``-u``, or ``-l`` is + given, and because only a full render emits the ``# Changelog`` + header. Once one release exists the file starts with that header + followed by a blank line, which git-cliff recognises, so later + releases are prepended above the previous section. + + Args: + tag: The release tag, including the leading 'v'. + prev: The previous release tag, if any. + runner: Command runner, injectable for tests. + """ + script = str(REPO_ROOT / "scripts" / "gen_changelog.py") + if prev is None: + runner([sys.executable, script, "--tag", tag, "-o", "CHANGELOG.md"]) + return + runner( + [ + sys.executable, + script, + "--tag", + tag, + *changelog_range(prev), + "--prepend", + "CHANGELOG.md", + ] + ) + + +def main() -> int: + """Run the release preparation process.""" + args = parse_args() + version = validate_version(args.version) + tag = f"v{version}" + + if tag_exists(tag): + raise SystemExit(f"error: tag {tag} already exists") + + prev = previous_tag() + if prev is None: + print( + "warning: no previous tag reachable; the changelog will cover " + "all history", + file=sys.stderr, + ) + else: + print(f"Previous reachable tag: {prev}") + + if args.dry_run: + print(f"[dry-run] bump pyproject.toml to {version}") + print("[dry-run] uv lock && uv lock --check") + print( + "[dry-run] prepend CHANGELOG.md " + f"({prev + '..HEAD' if prev else 'full history'})" + ) + print(f"[dry-run] commit: chore(release): {tag}") + if not args.no_tag: + print(f"[dry-run] create annotated tag {tag}") + if args.push: + print("[dry-run] push branch and tag") + return 0 + + if working_tree_dirty(): + raise SystemExit( + "error: working tree has uncommitted changes; commit or stash " + "them first" + ) + + print(f"Preparing release: {tag}") + bump_pyproject(REPO_ROOT / "pyproject.toml", version) + sync_lockfile() + generate_changelog(tag, prev) + + run(["git", "add", "pyproject.toml", "uv.lock", "CHANGELOG.md"]) + run(["git", "commit", "-m", f"chore(release): {tag}"]) + + if not args.no_tag: + run(["git", "tag", "-a", tag, "-m", tag]) + + if args.push: + run(["git", "push", "origin", "HEAD", "--follow-tags"]) + print(f"\nPushed {tag}. The release workflow will publish it.") + else: + print(f"\nCreated commit and tag {tag} locally.") + print("Push with: git push origin HEAD --follow-tags") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `uv run --extra dev pytest tests/test_release.py -v` +Expected: PASS, 21 tests (the two parametrized cases contribute 4 and 5). + +- [ ] **Step 5: Add the tox env** + +Add to `tox.ini` after `[testenv:changelog]`. Again, **not** in `env_list`. + +```ini +[testenv:release] +description = prepare a release commit and tag +runner = uv-venv-lock-runner +extras = dev +allowlist_externals = + git + uv +commands = + {envpython} scripts/release.py {posargs} +``` + +- [ ] **Step 6: Verify the dry run against the real repository** + +Run: `uv run --extra dev tox run -e release -- 0.1.0 --dry-run` + +Expected: the warning about no reachable tag (this repo has no `v*` tags yet), then the five `[dry-run]` lines. Confirm nothing changed: + +Run: `git status --porcelain` +Expected: no `pyproject.toml`, `uv.lock`, or `CHANGELOG.md` entries. + +Also confirm the guard works: + +Run: `uv run --extra dev tox run -e release -- 1.2 --dry-run` +Expected: FAIL with `'1.2' is not a valid version (expected X.Y.Z).` + +- [ ] **Step 7: Document the env** + +Add to `README.md` and `CONTRIBUTING.md`, after the changelog block from Task 2: + +````markdown +Prepare a release — bumps the version, regenerates the changelog, commits, +and tags: + +```bash +uv run tox -e release -- 0.1.0 +uv run tox -e release -- 0.1.0 --push +``` + +Pushing the tag triggers the `Release` workflow, which builds the +distributions and publishes a GitHub Release. +```` + +- [ ] **Step 8: Verify and commit** + +Run: `uv run --extra dev pre-commit run --all-files` +Expected: PASS. + +Run: `uv run --extra dev pytest tests/ -q` +Expected: PASS. + +```bash +chmod +x scripts/release.py +git add --chmod=+x scripts/release.py +git add tests/test_release.py tox.ini README.md CONTRIBUTING.md +git commit -m "feat(release): add release preparation script" +``` + +______________________________________________________________________ + +### Task 4: Release workflow + +**Files:** + +- Create: `.github/workflows/release.yaml` + +`README.md` needs no change here: it documents tox envs and the repository +layout, but has no per-workflow list to extend. The release *command* is +documented in Task 3, Step 7. + +**Interfaces:** + +- Consumes: `cliff.toml` (Task 2), the `chore(release): vX.Y.Z` commit and `vX.Y.Z` tag convention (Task 3), the existing `./.github/actions/setup-python-uv` composite action, and the existing `[testenv:build]` env which runs `scripts/validate_distribution.py`. + +- Produces: `.github/workflows/release.yaml`, which Task 5 adds to `WORKFLOW_FILES`. + +- [ ] **Step 1: Create the workflow** + +Two deliberate improvements over the reference: step 3 runs `tox -e build` rather than a bare `uv build`, reusing the existing `validate_distribution.py` so the published wheel is import-smoke-tested; and step 4 derives notes from git via `git-cliff --current` rather than scraping `CHANGELOG.md` with `awk`, which cannot drift from a mis-parsed heading. + +`fetch-depth: 0` is required — git-cliff needs full history and tags. + +```yaml +name: Release + +on: + push: + tags: + - v* + +permissions: + contents: read + +env: + PYTHON_VERSION: '3.10' + +jobs: + release: + name: Publish GitHub Release + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: ./.github/actions/setup-python-uv + with: + python-version: ${{ env.PYTHON_VERSION }} + # tomllib is 3.11+, and PYTHON_VERSION is 3.10 to match this template's + # floor, so the version is read with a regex instead. The pattern mirrors + # VERSION_LINE in scripts/release.py, which writes this same line. + - name: Verify tag matches the project version + run: | + tag_version="${GITHUB_REF_NAME#v}" + project_version=$(uv run --locked python -c \ + "import pathlib, re; print(re.search(r'(?m)^version\s*=\s*\"([^\"]+)\"', pathlib.Path('pyproject.toml').read_text()).group(1))") + if [ "$tag_version" != "$project_version" ]; then + echo "::error::Tag $GITHUB_REF_NAME does not match pyproject.toml version $project_version" + exit 1 + fi + - name: Build and verify package artifacts + run: uv run --locked --extra dev tox run -e build + - name: Extract release notes + run: | + uv run --locked --extra dev python scripts/gen_changelog.py \ + --current --strip header -o release-notes.md + if [ ! -s release-notes.md ]; then + echo "::error::No changelog content found for $GITHUB_REF_NAME" + exit 1 + fi + cat release-notes.md + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: ${{ github.ref_name }} + body_path: release-notes.md + files: | + dist/*.whl + dist/*.tar.gz + draft: false + prerelease: false + + # Publishing to PyPI is opt-in. To enable it: + # 1. Create a PyPI Trusted Publisher for this repository, pointing at + # workflow `release.yaml` and environment `pypi`. + # See https://docs.pypi.org/trusted-publishers/ + # 2. Create a GitHub environment named `pypi`. + # 3. Uncomment the job below. + # No API token is needed; authentication uses OIDC. + # + # publish: + # name: Publish to PyPI + # needs: release + # runs-on: ubuntu-latest + # timeout-minutes: 15 + # environment: pypi + # permissions: + # id-token: write + # steps: + # - uses: actions/checkout@v6 + # - uses: ./.github/actions/setup-python-uv + # with: + # python-version: ${{ env.PYTHON_VERSION }} + # - name: Build distributions + # run: uv run --locked --extra dev tox run -e build + # - name: Publish + # uses: pypa/gh-action-pypi-publish@release/v1 +``` + +- [ ] **Step 2: Verify the workflow parses and matches repo conventions** + +Run: `uv run --extra dev pre-commit run --all-files --files .github/workflows/release.yaml` + +Expected: PASS. `check-yaml` validates syntax and `pretty-format-yaml` enforces 2-space indentation — if it reformats the file, accept its output and re-run. + +Confirm the referenced tox env and composite action exist: + +Run: `uv run --extra dev tox list | grep build && ls .github/actions/setup-python-uv/action.yaml` +Expected: the `build` env is listed and the action file exists. + +- [ ] **Step 3: Verify the release-notes command works locally** + +This is the one workflow step that can be exercised without pushing a tag. `--current` needs a tag to resolve, so create a throwaway one, then delete it. + +```bash +git tag -a v0.0.1-test -m v0.0.1-test +uv run --extra dev python scripts/gen_changelog.py --current --strip header +git tag -d v0.0.1-test +``` + +Expected: a `## 0.0.1-test - ` section with grouped bullets and no `# Changelog` header line. Confirm the tag is gone afterwards with `git tag -l`. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/release.yaml +git commit -m "ci(release): publish GitHub Releases from version tags" +``` + +______________________________________________________________________ + +### Task 5: Template bootstrap integration + +**Files:** + +- Modify: `scripts/bootstrap_template.py` (placeholder constants, `WORKFLOW_FILES`, `PACKAGE_FILES`, new `update_spdx_copyright`, `main`) +- Test: `tests/test_bootstrap_template.py` (extend) + +**Interfaces:** + +- Consumes: `REUSE.toml` and the header format (Task 1), `cliff.toml` and `scripts/gen_changelog.py` (Task 2), `scripts/release.py` (Task 3), `.github/workflows/release.yaml` (Task 4). + +- Produces: `PLACEHOLDER_COPYRIGHT` and `update_spdx_copyright(paths, *, project_title, dry_run)`, relied on by Task 6's smoke assertions. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_bootstrap_template.py`. Import `update_spdx_copyright` and `PLACEHOLDER_COPYRIGHT` by extending the existing `from scripts.bootstrap_template import (...)` block at the top of the file. + +```python +SPDX_HEADER = ( + "# SPDX-FileCopyrightText: 2026 the Python Template contributors\n" + "#\n" + "# SPDX-License-Identifier: BSD-2-Clause\n" + "\n" + '"""Module."""\n' +) +REUSE_TOML = ( + "version = 1\n" + "\n" + "[[annotations]]\n" + 'path = ["README.md"]\n' + 'precedence = "aggregate"\n' + 'SPDX-FileCopyrightText = "2026 the Python Template contributors"\n' + 'SPDX-License-Identifier = "BSD-2-Clause"\n' +) + + +def test_update_spdx_copyright_rewrites_inline_headers(tmp_path): + module = tmp_path / "mod.py" + module.write_text(SPDX_HEADER, encoding="utf-8") + + update_spdx_copyright( + [module], + project_title="Acme Tool", + dry_run=False, + ) + + content = module.read_text(encoding="utf-8") + assert "2026 the Acme Tool contributors" in content + assert PLACEHOLDER_COPYRIGHT not in content + + +def test_update_spdx_copyright_rewrites_reuse_toml(tmp_path): + reuse_toml = tmp_path / "REUSE.toml" + reuse_toml.write_text(REUSE_TOML, encoding="utf-8") + + update_spdx_copyright( + [reuse_toml], + project_title="Acme Tool", + dry_run=False, + ) + + content = reuse_toml.read_text(encoding="utf-8") + assert 'SPDX-FileCopyrightText = "2026 the Acme Tool contributors"' in content + + +def test_update_spdx_copyright_preserves_license_identifier(tmp_path): + module = tmp_path / "mod.py" + module.write_text(SPDX_HEADER, encoding="utf-8") + + update_spdx_copyright( + [module], + project_title="Acme Tool", + dry_run=False, + ) + + content = module.read_text(encoding="utf-8") + assert "# SPDX-License-Identifier: BSD-2-Clause" in content + + +def test_update_spdx_copyright_dry_run_leaves_files_untouched(tmp_path): + module = tmp_path / "mod.py" + module.write_text(SPDX_HEADER, encoding="utf-8") + + update_spdx_copyright( + [module], + project_title="Acme Tool", + dry_run=True, + ) + + assert module.read_text(encoding="utf-8") == SPDX_HEADER + + +def test_update_spdx_copyright_ignores_missing_files(tmp_path): + update_spdx_copyright( + [tmp_path / "absent.py"], + project_title="Acme Tool", + dry_run=False, + ) + + +def test_release_workflow_is_a_bootstrap_target(): + from scripts.bootstrap_template import WORKFLOW_FILES + + assert Path(".github/workflows/release.yaml") in WORKFLOW_FILES + + +def test_spdx_files_cover_the_new_automation_scripts(): + from scripts.bootstrap_template import SPDX_FILES + + for path in ( + Path("REUSE.toml"), + Path("scripts/release.py"), + Path("scripts/gen_changelog.py"), + Path("scripts/add_spdx_headers.py"), + ): + assert path in SPDX_FILES +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `uv run --extra dev pytest tests/test_bootstrap_template.py -v -k "spdx or workflow_is_a_bootstrap or automation_files"` +Expected: FAIL — `ImportError: cannot import name 'update_spdx_copyright'` + +- [ ] **Step 3: Add the placeholder constant** + +In `scripts/bootstrap_template.py`, after `PLACEHOLDER_DESCRIPTION`: + +```python +PLACEHOLDER_COPYRIGHT = "the Python Template contributors" +``` + +- [ ] **Step 4: Register the new files** + +Add `Path(".github/workflows/release.yaml")` to `WORKFLOW_FILES`, keeping alphabetical order (after `quality.yaml`). It feeds both `PACKAGE_FILES` and `REPOSITORY_FILES` via the existing splat. + +Do **not** add `cliff.toml`, `REUSE.toml`, or the three new scripts to `PACKAGE_FILES`. `PACKAGE_FILES` exists to substitute the literal strings `project_name` and `python-template`, and none of those files contain either — registering them there would be a no-op that misleads the next reader. Their only template-varying content is the SPDX copyright holder, handled by `SPDX_FILES` below. + +Leave `PYTHON_VERSION_WORKFLOW_FILES` unchanged — `release.yaml` has a `PYTHON_VERSION` env var but no version matrix, and `update_ci_workflow` would be a no-op beyond that single substitution. This means the release workflow keeps `PYTHON_VERSION: '3.10'` even when a generated project raises its minimum; that is harmless because the release build is version-independent, and 3.10 is deliberate given the `tomllib` constraint noted in Task 4. + +Add a module-level tuple listing every file carrying the copyright string, after `REPOSITORY_FILES`: + +```python +SPDX_FILES = ( + Path("REUSE.toml"), + Path("docs/conf.py"), + Path("examples/say_hi.py"), + Path("scripts/add_spdx_headers.py"), + Path("scripts/bootstrap_template.py"), + Path("scripts/gen_changelog.py"), + Path("scripts/release.py"), + Path("scripts/update_coverage_readme.py"), + Path("scripts/validate_distribution.py"), + Path("tests/test_add_spdx_headers.py"), + Path("tests/test_bootstrap_template.py"), + Path("tests/test_greeter.py"), + Path("tests/test_release.py"), + Path("tests/test_template_smoke.py"), +) +``` + +The package's own `.py` files are handled separately in Step 6, because the package directory gets renamed. + +- [ ] **Step 5: Write `update_spdx_copyright`** + +Add after `update_readme`: + +```python +def update_spdx_copyright( + paths: list[Path], + *, + project_title: str, + dry_run: bool, +) -> None: + """Rewrite the SPDX copyright holder in the given files. + + Args: + paths: Files that may contain the placeholder copyright holder. + project_title: Human-readable project title. + dry_run: Print planned changes without writing them. + """ + holder = f"the {project_title} contributors" + for path in paths: + replace_text( + path, + {PLACEHOLDER_COPYRIGHT: holder}, + dry_run=dry_run, + ) +``` + +`replace_text` already skips missing files and prints `updated `, which satisfies the missing-file test. + +- [ ] **Step 6: Call it from `main`** + +Insert immediately **before** `rename_package_dir(...)` at the end of `main()`, so the package files are still at their placeholder path when rewritten: + +```python + update_spdx_copyright( + [ + *SPDX_FILES, + *sorted(PACKAGE_DIR.glob("*.py")), + ], + project_title=project_title, + dry_run=args.dry_run, + ) + rename_package_dir(package_name, dry_run=args.dry_run) +``` + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `uv run --extra dev pytest tests/test_bootstrap_template.py -v` +Expected: PASS, including the pre-existing tests. + +- [ ] **Step 8: Verify bootstrap end-to-end with a dry run** + +Run: `uv run --extra dev python scripts/bootstrap_template.py acme-tool --dry-run` + +Expected: an `updated ` line for each entry in `SPDX_FILES` plus each `project_name/*.py`, alongside the pre-existing output. `cliff.toml` will **not** appear — it carries no copyright header (it is covered by the `REUSE.toml` bulk annotation) and no `project_name` placeholder. `.github/workflows/release.yaml` will also not appear, for the same reason: it is in `WORKFLOW_FILES` for consistency but contains neither placeholder string. Confirm nothing was written: + +Run: `git status --porcelain` +Expected: no modifications. + +- [ ] **Step 9: Verify and commit** + +Run: `uv run --extra dev pre-commit run --all-files` +Expected: PASS. + +```bash +git add scripts/bootstrap_template.py tests/test_bootstrap_template.py +git commit -m "feat(bootstrap): substitute SPDX and release metadata" +``` + +______________________________________________________________________ + +### Task 6: Template smoke test coverage + +**Files:** + +- Modify: `tests/test_template_smoke.py:22-42` (`PLACEHOLDER_CHECK_PATHS`) and `tests/test_template_smoke.py:70-121` (the single smoke test) + +**Interfaces:** + +- Consumes: everything from Tasks 1-5, exercised through the real `bootstrap_template.py` run the smoke test already performs. +- Produces: nothing consumed downstream. + +The smoke test is a single test — `test_generated_project_bootstraps_and_builds(tmp_path)` at line 70 — that copytrees the repo, runs `bootstrap_template.py demo-service`, syncs, builds, and finally loops over the `PLACEHOLDER_CHECK_PATHS` tuple (line 22) asserting no leftover placeholders. Extend those two existing structures; do not add a second harness or invent a fixture. + +Because bootstrap is invoked with `demo-service` and no `--project-title`, `resolve_metadata` derives the title `Demo Service`, so the expected copyright holder is exactly `the Demo Service contributors`. + +- [ ] **Step 1: Extend `PLACEHOLDER_CHECK_PATHS`** + +Add these six entries, preserving the tuple's existing alphabetical grouping (workflow paths first, then root and subdirectory files): + +```python + ".github/workflows/release.yaml", + "REUSE.toml", + "cliff.toml", + "scripts/add_spdx_headers.py", + "scripts/gen_changelog.py", + "scripts/release.py", +``` + +- [ ] **Step 2: Add the SPDX assertions to the existing test** + +Append to the end of `test_generated_project_bootstraps_and_builds`, immediately after the existing `for relative_path in PLACEHOLDER_CHECK_PATHS:` loop: + +```python + reuse_toml = (generated_repo / "REUSE.toml").read_text(encoding="utf-8") + assert "the Demo Service contributors" in reuse_toml + assert "the Python Template contributors" not in reuse_toml + + package_modules = sorted( + path + for path in (generated_repo / "demo_service").glob("*.py") + if path.stat().st_size > 0 + ) + assert package_modules + + for module in package_modules: + content = module.read_text(encoding="utf-8") + assert "SPDX-License-Identifier: BSD-2-Clause" in content, str(module) + assert "the Demo Service contributors" in content, str(module) + assert "the Python Template contributors" not in content, str(module) +``` + +- [ ] **Step 3: Run the smoke test** + +The suite is gated behind an environment variable. + +Run: `RUN_TEMPLATE_SMOKE=1 uv run --extra dev --extra docs pytest tests/test_template_smoke.py -v` + +Expected: PASS. If the copyright assertions fail, a path is missing from `SPDX_FILES` in Task 5 — add it there rather than weakening the test. If `uv sync --locked` fails, `uv.lock` was not committed after adding `git-cliff` in Task 2. + +- [ ] **Step 4: Run it the way CI does** + +Run: `uv run --locked --extra dev --extra docs tox run -e template` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_template_smoke.py +git commit -m "test(template): assert release and SPDX bootstrap substitution" +``` + +______________________________________________________________________ + +### Task 7: Full verification + +**Files:** none modified unless a check fails. + +**Interfaces:** + +- Consumes: Tasks 1-6. + +- Produces: nothing. + +- [ ] **Step 1: Run every quality gate CI runs** + +Run each and confirm PASS: + +```bash +uv run --locked --extra dev pre-commit run --all-files +uv run --locked --extra dev tox run -e type +uv run --locked --extra dev tox run -e coverage +uv run --locked --extra dev tox run -e build +uv lock --check +``` + +`coverage` enforces `fail_under = 90` on the `project_name` package only, so the new scripts do not affect it. + +- [ ] **Step 2: Confirm the release and changelog envs stayed out of the default set** + +Run: `uv run --extra dev tox list --no-desc` + +Expected: `changelog` and `release` appear in the output, but under the "additional environments" section rather than the default list. Cross-check by confirming `env_list` in `tox.ini` contains neither name. + +- [ ] **Step 3: Confirm the changelog covers the release commits themselves** + +Run: `uv run --extra dev tox run -e changelog -- --tag v0.1.0` + +Expected: every commit from this plan appears under the group its type maps to — the three `feat:` commits under `Features`, the `ci:` commit under `Build & CI`, the `test:` commit under `Testing`, and the `docs:` spec and plan commits under `Documentation`. + +- [ ] **Step 4: Report results** + +Report the actual command output for each gate. If any failed, say so with the output rather than describing the work as complete. diff --git a/docs/superpowers/specs/2026-07-29-release-changelog-spdx-design.md b/docs/superpowers/specs/2026-07-29-release-changelog-spdx-design.md new file mode 100644 index 0000000..b1f853f --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-release-changelog-spdx-design.md @@ -0,0 +1,368 @@ +# Release automation, changelog generation, and SPDX headers + +Date: 2026-07-29 + +## Goal + +Bring three capabilities from +[glacier-project/frost-planner](https://github.com/glacier-project/frost-planner) +into this template: + +1. Tag-triggered GitHub Release publishing. +1. `CHANGELOG.md` generation from git history via git-cliff. +1. SPDX/REUSE licensing headers on source files. + +Because this repository is a *template*, every added file must also survive +`scripts/bootstrap_template.py` — placeholders substituted, copyright holder +rewritten — and be covered by the template smoke test. + +## Decisions + +| Question | Decision | Rationale | +| ----------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------- | +| SPDX headers | Auto-insert, then verify | The reference only lints; writing headers automatically removes the manual step. | +| Changelog format | Grouped by conventional-commit type | This repository's history is consistently conventional, unlike the reference's. | +| Release scope | GitHub Release, with a commented PyPI stub | Works with no secrets in every generated repo; PyPI is opt-in. | +| Copyright holder | `the contributors` | Avoids editing a per-author name into every file as contributors change. | +| Entrypoints | Python scripts plus tox envs | Matches this repo's all-Python `scripts/` and tox-driven convention; unit-testable. | +| Commit scope in bullets | Omitted | Keeps bullets short; scope is recoverable from the hash. | +| Root `LICENSE` | Kept alongside `LICENSES/` | GitHub license detection reads the root file. | + +## Divergences from the reference + +These are deliberate improvements, not accidental drift. + +- **`uv.lock` version sync.** `uv.lock` pins the project's own + `version = "0.0.0"`. CI runs `uv run --locked`, so bumping only + `pyproject.toml` would break every workflow on the release commit. The + reference's `release.sh` does not handle this. `release.py` runs `uv lock` + and `uv lock --check`, and commits the lockfile. +- **`git-cliff` as a pinned dependency.** The reference fetches it with + unpinned `uvx`. Here it is a `dev` extra, so releases are reproducible. +- **Release artifacts are smoke-tested.** The release workflow runs + `tox -e build`, reusing the existing `scripts/validate_distribution.py`, + instead of a bare `uv build`. +- **Release notes come from git-cliff `--current`,** not an `awk` scrape of + `CHANGELOG.md`, so notes cannot drift from a mis-parsed heading. +- **Template integration.** No equivalent exists upstream; the reference is + not a template. + +## 1. SPDX / REUSE layer + +New files: + +- `LICENSES/BSD-2-Clause.txt` — license text, copied from the existing root + `LICENSE`. +- `REUSE.toml` — bulk annotations covering every non-Python tracked file. +- `scripts/add_spdx_headers.py` — wraps `reuse annotate`. + +Two pre-commit hooks, in this order: + +```yaml +- repo: local + hooks: + - id: reuse-annotate + name: add SPDX headers + entry: python scripts/add_spdx_headers.py + language: python + additional_dependencies: ['reuse>=6.2,<7'] + types: [python] +- repo: https://github.com/fsfe/reuse-tool + rev: v6.2.0 + hooks: + - id: reuse +``` + +`add_spdx_headers.py` invokes `reuse annotate` over the staged Python files +pre-commit passes as arguments, with: + +``` +--skip-existing --merge-copyrights --skip-unrecognised +-y -c "the contributors" -l BSD-2-Clause +``` + +`reuse` 6.2.0 requires Python >= 3.10, matching this template's +`requires-python = ">=3.10,<4"`. + +### First-commit behavior + +pre-commit fails any hook that modifies files — it compares file hashes and +ignores the hook's exit code. So the first `git commit` on a file with no +header aborts, with the header now written; re-running `git commit` succeeds. +This is the same behavior as `trailing-whitespace`. The benefit is never +hand-writing a header, not an uninterrupted commit. + +`fail_fast: true` is already set globally, so the `reuse` lint hook does not +run in the same pass that `reuse-annotate` modifies files. It runs on the +retry. + +### Header vs. bulk annotation + +Inline headers go in non-empty tracked Python files only: + +``` +project_name/__init__.py tests/test_bootstrap_template.py +project_name/greeter.py tests/test_greeter.py +docs/conf.py tests/test_template_smoke.py +examples/say_hi.py scripts/release.py +scripts/add_spdx_headers.py scripts/gen_changelog.py +scripts/bootstrap_template.py +scripts/update_coverage_readme.py +scripts/validate_distribution.py +``` + +`tests/__init__.py` is 0 bytes and is deliberately excluded: `reuse lint` +skips zero-byte files, so annotating it would add content to an intentionally +empty file for no compliance gain. `add_spdx_headers.py` therefore skips +zero-byte inputs, matching `reuse lint`'s own behavior. + +Everything else is covered by `REUSE.toml`, so non-Python files are never +rewritten. The annotation block covers exactly: + +```toml +path = [ + ".devcontainer/**", + ".env", + ".github/**", + ".gitignore", + ".pre-commit-config.yaml", + ".pylintrc", + ".readthedocs.yaml", + "AGENTS.md", + "CHANGELOG.md", + "CLAUDE.md", + "CONTRIBUTING.md", + "Dockerfile", + "README.md", + "cliff.toml", + "docs/**", + "pyproject.toml", + "tox.ini", + "uv.lock", +] +``` + +`docs/**` covers `docs/*.md` and this `docs/superpowers/specs/` directory. +`AGENTS.md` and `CLAUDE.md` are listed because they are untracked but *not* +gitignored, and `reuse lint` only skips VCS-ignored files — it would otherwise +flag them. + +### Required `.gitignore` additions + +`reuse lint` skips gitignored files, but `.ruff_cache/` and `.mypy_cache/` +are currently **not** ignored, so their contents would fail the lint. Both are +tool caches that belong in `.gitignore` regardless (`.mypy_cache` is a leftover +from before the pyrefly migration in `0383620`). Adding both entries is part of +this work — it is required for the hook to pass, not unrelated cleanup. + +Verified against `reuse` 6.2.0 in a scratch repository: `reuse lint` ignores +the root `LICENSE`, the `LICENSES/` directory, `REUSE.toml` itself, and +zero-byte files. The root `LICENSE` therefore needs **no** `REUSE.toml` entry, +and the zero-byte skip above is confirmed behavior rather than an assumption. + +`reuse annotate` writes this exact three-line form, preserving any shebang +above it: + +```python +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause +``` + +Note the `#` separator line. The reference repository uses a compact two-line +header, which an older `reuse` produced. This design keeps the tool's native +output rather than post-processing it, so headers stay byte-identical to what +`reuse annotate` regenerates. The difference is cosmetic. + +## 2. Changelog layer + +`git-cliff>=2.13,<3` is added to the `dev` extra. It ships platform wheels on +PyPI that bundle the binary, so it resolves into `uv.lock` normally. + +`cliff.toml`: + +```toml +[changelog] +header = "# Changelog\n\n" +body = """ +{% if version %}\ +## {{ version | trim_start_matches(pat="v") }} - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ +## Unreleased +{% endif %} +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | striptags | trim | upper_first }} + +{% for commit in commits %}\ +- [{{ commit.id | truncate(length=7, end="") }}] {{ commit.message | upper_first }} +{% endfor %}\ +{% endfor %}\n +""" +trim = true +footer = "" + +[git] +conventional_commits = true +filter_unconventional = false +split_commits = false +protect_breaking_commits = false +filter_commits = false +tag_pattern = "v[0-9]*" +topo_order = false +sort_commits = "oldest" +``` + +Commit parsers, in order — skips first, then type mapping, then a catch-all: + +| Pattern | Group | +| ------------------------- | ------------- | +| `^chore\(release\)` | skipped | +| `^Merge ` | skipped | +| `^feat` | Features | +| `^fix` | Bug Fixes | +| `^perf` | Performance | +| `^refactor` | Refactoring | +| `^docs` | Documentation | +| `^test` | Testing | +| `^(build\|ci)` | Build & CI | +| `^(chore\|style\|format)` | Chores | +| `.*` | Other | + +Group names carry `` numeric prefixes stripped by `striptags`. This +is git-cliff's documented idiom for forcing group order; `group_by` otherwise +sorts alphabetically. + +`filter_unconventional = false` sends unconventional commits to *Other* +rather than dropping them. + +Resulting format: + +```markdown +## 0.1.0 - 2026-07-29 + +### Features + +- [49157ab] add current_python_version function + +### Bug Fixes + +- [a3bd78d] run one tox Python env per matrix job +``` + +`CHANGELOG.md` is currently 0 bytes. `git-cliff --prepend` does not insert the +`[changelog] header`, so the file is seeded with `# Changelog\n\n` as part of +this work. + +## 3. Release layer + +### `scripts/gen_changelog.py` + +Passes its arguments through to `git-cliff --config cliff.toml`. Preview to +stdout by default; `-o CHANGELOG.md` overwrites. + +### `scripts/release.py X.Y.Z [--push] [--no-tag] [--dry-run]` + +Same contract as the reference's `release.sh`, plus lockfile handling: + +1. Validate `X.Y.Z` against `^\d+\.\d+\.\d+([.-].+)?$`; abort if the tag + already exists or the working tree is dirty. +1. Resolve the previous reachable tag with + `git describe --tags --abbrev=0 --match='v[0-9]*' HEAD`. If none, the + changelog covers all history and a warning is printed. +1. Bump `version` in `pyproject.toml`. +1. Run `uv lock`, then `uv lock --check` to confirm the lockfile is in sync. +1. `git-cliff --config cliff.toml --tag vX.Y.Z --prepend CHANGELOG.md`. +1. Commit `pyproject.toml`, `uv.lock`, and `CHANGELOG.md` as + `chore(release): vX.Y.Z`. +1. Unless `--no-tag`, create annotated tag `vX.Y.Z`. +1. With `--push`, `git push origin HEAD --follow-tags`. + +`--dry-run` prints the planned steps and exits without touching the tree, and +so does not require a clean tree. + +### tox envs + +`[testenv:changelog]` and `[testenv:release]`, both with +`runner = uv-venv-lock-runner`, `extras = dev`, and +`allowlist_externals = git, uv`. + +Both are deliberately kept **out of** `env_list`. Every CI workflow invokes +tox as `tox run -e `, but a developer running bare `uv run tox` locally +executes everything in `env_list` — which would fire a release attempt. Envs +outside `env_list` remain runnable via `tox run -e release`. + +``` +uv run tox run -e changelog +uv run tox run -e release -- 0.1.0 --push +``` + +### `.github/workflows/release.yaml` + +Triggered by `push: tags: ['v*']`, with `permissions: contents: write`. Uses +the repository's own `./.github/actions/setup-python-uv` composite action +rather than inlining uv setup, matching the other workflows. + +Steps: + +1. `actions/checkout@v6` with `fetch-depth: 0` — git-cliff needs full history. +1. Verify the tag matches `pyproject.toml`'s version; fail with + `::error::` if not. +1. `uv run --locked --extra dev tox run -e build` — builds sdist and wheel and + import-smoke-tests the wheel via `scripts/validate_distribution.py`. +1. `git-cliff --config cliff.toml --current --strip header -o release-notes.md`; + fail if the result is empty. +1. `softprops/action-gh-release@v2` with `body_path: release-notes.md` and + `dist/*.whl`, `dist/*.tar.gz`. +1. A commented-out `publish` job — `needs: release`, + `environment: pypi`, `permissions: id-token: write`, + `pypa/gh-action-pypi-publish@release/v1` — with a comment explaining that + enabling it requires configuring a PyPI Trusted Publisher. + +## 4. Template integration + +`scripts/bootstrap_template.py` changes: + +- Add `PLACEHOLDER_COPYRIGHT = "the Python Template contributors"` beside the + existing placeholders. +- Add `Path(".github/workflows/release.yaml")` to `WORKFLOW_FILES`, which + feeds both `PACKAGE_FILES` and `REPOSITORY_FILES`. +- Add `cliff.toml`, `REUSE.toml`, `scripts/release.py`, + `scripts/gen_changelog.py`, and `scripts/add_spdx_headers.py` to + `PACKAGE_FILES` so `project_name` is substituted in them. +- Add `update_spdx_copyright()`, which rewrites + `the Python Template contributors` to `the contributors` in + `REUSE.toml` and in every inline header, and call it from `main()`. + +The existing `PYTHON_VERSION_WORKFLOW_FILES` tuple is left unchanged: +`release.yaml` pins no Python version matrix. + +## 5. Testing + +- `tests/test_bootstrap_template.py` — `update_spdx_copyright()` rewrites + `REUSE.toml` and inline headers; the newly added files are covered by + placeholder substitution. +- `tests/test_release.py` — against a temporary git repository: version-format + validation, refusal on an existing tag, refusal on a dirty tree, + `pyproject.toml` bump, `uv.lock` version sync, commit message and tag + creation, and `--dry-run` leaving the tree untouched. +- `tests/test_template_smoke.py` — the generated project has no + `project_name` or `python-template` leftovers in the new files, and its + headers name the generated project. + +Manual verification before handing back: + +- `pre-commit run --all-files` passes on a second run (first run writes + headers). +- `uv run tox run -e changelog` produces sane grouped output against real + history. +- `uv run tox run -e release -- 0.1.0 --dry-run` prints the expected plan. +- `uv lock --check` passes. + +## Out of scope + +- No separate `tox -e license` env. The pre-commit hook plus the existing + `quality.yaml` pre-commit job already run REUSE checks in CI. +- No PyPI publishing enabled by default; the stub is commented out. +- No changes to existing workflows other than adding `release.yaml`. diff --git a/examples/say_hi.py b/examples/say_hi.py index 1f8b625..478ff67 100644 --- a/examples/say_hi.py +++ b/examples/say_hi.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + from project_name.greeter import Greeter if __name__ == "__main__": diff --git a/project_name/__init__.py b/project_name/__init__.py index 6f02084..045a84e 100644 --- a/project_name/__init__.py +++ b/project_name/__init__.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + """Public package interface for the template project.""" from project_name.greeter import Greeter, Language, get_language diff --git a/project_name/greeter.py b/project_name/greeter.py index 7d01f92..44ba165 100644 --- a/project_name/greeter.py +++ b/project_name/greeter.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + import logging from enum import Enum from typing import ClassVar diff --git a/pyproject.toml b/pyproject.toml index 6e7763e..eacf73a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ license = "BSD-2-Clause" [project.optional-dependencies] dev = [ "build>=1.3.0,<2", + "git-cliff>=2.13,<3", "hatchling>=1.29.0,<2", "radon>=6.0.1,<7", "lizard>=1.22.1,<2", diff --git a/scripts/add_spdx_headers.py b/scripts/add_spdx_headers.py new file mode 100755 index 0000000..c64ff44 --- /dev/null +++ b/scripts/add_spdx_headers.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +"""Insert SPDX licensing headers into Python files that lack them.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from datetime import date +from pathlib import Path + +COPYRIGHT_HOLDER = "the Python Template contributors" +LICENSE_ID = "BSD-2-Clause" + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser() + parser.add_argument( + "paths", + nargs="*", + help="Files to annotate. Supplied by pre-commit.", + ) + parser.add_argument( + "--year", + default=str(date.today().year), + help="Copyright year to write. Defaults to the current year.", + ) + parser.add_argument( + "--copyright", + default=COPYRIGHT_HOLDER, + dest="copyright_holder", + help="Copyright holder to write into the header.", + ) + parser.add_argument( + "--license", + default=LICENSE_ID, + dest="license_id", + help="SPDX license identifier to write into the header.", + ) + return parser.parse_args() + + +def annotatable(paths: list[str]) -> list[str]: + """Return the paths reuse can annotate. + + Zero-byte files are skipped because ``reuse lint`` ignores them, so + annotating them would add content to intentionally empty files without + improving compliance. + + Args: + paths: Candidate file paths. + + Returns: + The subset of paths that are non-empty regular files. + """ + keep = [] + for raw in paths: + path = Path(raw) + if path.is_file() and path.stat().st_size > 0: + keep.append(raw) + return keep + + +def build_command( + paths: list[str], + *, + year: str, + copyright_holder: str, + license_id: str, +) -> list[str]: + """Build the ``reuse annotate`` command for the given paths. + + Args: + paths: Files to annotate. + year: Copyright year to write. + copyright_holder: Copyright holder to write. + license_id: SPDX license identifier to write. + + Returns: + The command as an argument list. + """ + return [ + sys.executable, + "-m", + "reuse", + "annotate", + "--skip-existing", + "--merge-copyrights", + "--skip-unrecognised", + "--year", + year, + "--copyright", + copyright_holder, + "--license", + license_id, + *paths, + ] + + +def main() -> int: + """Annotate the requested files and return the reuse exit status.""" + args = parse_args() + paths = annotatable(args.paths) + if not paths: + return 0 + + command = build_command( + paths, + year=args.year, + copyright_holder=args.copyright_holder, + license_id=args.license_id, + ) + return subprocess.run(command, check=False).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/bootstrap_template.py b/scripts/bootstrap_template.py index 897e521..0c3025a 100755 --- a/scripts/bootstrap_template.py +++ b/scripts/bootstrap_template.py @@ -1,4 +1,9 @@ #!/usr/bin/env python3 + +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + """Bootstrap a repository created from this template.""" from __future__ import annotations @@ -13,6 +18,7 @@ PLACEHOLDER_AUTHOR = "Mario Potato" PLACEHOLDER_AUTHOR_EMAIL = "mario.potato@univr.it" PLACEHOLDER_DESCRIPTION = "A simple template project." +PLACEHOLDER_COPYRIGHT = "the Python Template contributors" SUPPORTED_PYTHON_VERSIONS = ("3.10", "3.11", "3.12", "3.13", "3.14") WORKFLOW_FILES = ( @@ -21,6 +27,7 @@ Path(".github/workflows/documentation.yaml"), Path(".github/workflows/package.yaml"), Path(".github/workflows/quality.yaml"), + Path(".github/workflows/release.yaml"), Path(".github/workflows/security.yaml"), Path(".github/workflows/template-smoke.yaml"), Path(".github/workflows/tests.yaml"), @@ -55,6 +62,27 @@ Path("README.md"), *WORKFLOW_FILES, ) +# Files carrying the placeholder SPDX copyright holder. The package's own +# modules are added at call time, because the package directory is renamed. +SPDX_FILES = ( + Path("REUSE.toml"), + Path("docs/conf.py"), + Path("examples/say_hi.py"), + Path("scripts/add_spdx_headers.py"), + Path("scripts/bootstrap_template.py"), + Path("scripts/check_yaml_sexagesimal.py"), + Path("scripts/gen_changelog.py"), + Path("scripts/release.py"), + Path("scripts/update_coverage_readme.py"), + Path("scripts/validate_distribution.py"), + Path("tests/test_add_spdx_headers.py"), + Path("tests/test_bootstrap_template.py"), + Path("tests/test_check_yaml_sexagesimal.py"), + Path("tests/test_gen_changelog.py"), + Path("tests/test_greeter.py"), + Path("tests/test_release.py"), + Path("tests/test_template_smoke.py"), +) DOCS_INDEX = Path("docs/index.md") DOCS_CONF = Path("docs/conf.py") PACKAGE_DIR = Path(PLACEHOLDER_PACKAGE) @@ -512,6 +540,28 @@ def update_readme( path.write_text(updated, encoding="utf-8") +def update_spdx_copyright( + paths: list[Path], + *, + project_title: str, + dry_run: bool, +) -> None: + """Rewrite the SPDX copyright holder in the given files. + + Args: + paths: Files that may contain the placeholder copyright holder. + project_title: Human-readable project title. + dry_run: Print planned changes without writing them. + """ + holder = f"the {project_title} contributors" + for path in paths: + replace_text( + path, + {PLACEHOLDER_COPYRIGHT: holder}, + dry_run=dry_run, + ) + + def rename_package_dir(package_name: str, *, dry_run: bool) -> None: """Rename the placeholder package directory.""" target_dir = Path(package_name) @@ -608,6 +658,14 @@ def main() -> int: minimum_python_version=minimum_python_version, dry_run=args.dry_run, ) + update_spdx_copyright( + [ + *SPDX_FILES, + *sorted(PACKAGE_DIR.glob("*.py")), + ], + project_title=project_title, + dry_run=args.dry_run, + ) rename_package_dir(package_name, dry_run=args.dry_run) return 0 diff --git a/scripts/check_yaml_sexagesimal.py b/scripts/check_yaml_sexagesimal.py new file mode 100755 index 0000000..31f9812 --- /dev/null +++ b/scripts/check_yaml_sexagesimal.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +"""Flag unquoted HH:MM values in YAML files. + +YAML 1.1 reads a bare ``06:00`` as a sexagesimal integer, so Ruby's Psych +parser -- which GitHub's Dependabot uses -- sees ``21600`` where a string +was intended and rejects the file. Python parsers follow YAML 1.2 and read +the same scalar as a string, so neither the ``check-yaml`` hook nor JSON +Schema validation can catch this: they never see a number. + +The scan is line based rather than parser based for that reason. Quoting +the value fixes it, and ``pretty-format-yaml`` needs ``--preserve-quotes`` +so it does not strip the quotes back off. +""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +# Mirrors the sexagesimal pattern in Ruby's Psych scalar scanner. +SEXAGESIMAL = re.compile(r"[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+") + + +def sexagesimal_to_int(value: str) -> int: + """Convert a sexagesimal scalar the way Ruby's Psych parser does. + + Args: + value: A scalar such as ``06:00``. + + Returns: + The integer a YAML 1.1 parser produces for that scalar. + """ + parts = value.replace("_", "").split(":") + return sum( + int(part) * 60 ** abs(index - 2) for index, part in enumerate(parts) + ) + + +def bare_sexagesimal_values(content: str) -> list[tuple[int, str]]: + """Find unquoted sexagesimal scalars in YAML content. + + Args: + content: The YAML document text. + + Returns: + A list of (line number, value) pairs, in file order. + """ + findings: list[tuple[int, str]] = [] + for number, raw_line in enumerate(content.splitlines(), start=1): + line = raw_line.split(" #", 1)[0].strip() + if not line or line.startswith("#"): + continue + if line.startswith("- "): + line = line[2:].strip() + + _, separator, value = line.partition(": ") + candidate = value.strip() if separator else line + if SEXAGESIMAL.fullmatch(candidate): + findings.append((number, candidate)) + return findings + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse command-line arguments. + + Args: + argv: Arguments to parse, or None to read them from the command + line. + + Returns: + The parsed arguments. + """ + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("paths", nargs="*", help="YAML files to check.") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Report every unquoted sexagesimal value in the given files. + + Args: + argv: Arguments to parse, or None to read them from the command + line. + + Returns: + 1 if any file contains an unquoted sexagesimal value, else 0. + """ + args = parse_args(argv) + found = False + for path in args.paths: + content = Path(path).read_text(encoding="utf-8") + for number, value in bare_sexagesimal_values(content): + print( + f"{path}:{number}: {value} is read as the integer " + f"{sexagesimal_to_int(value)} by YAML 1.1 parsers such as " + f"Dependabot's; quote it as '{value}'" + ) + found = True + return 1 if found else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/gen_changelog.py b/scripts/gen_changelog.py new file mode 100755 index 0000000..53f573c --- /dev/null +++ b/scripts/gen_changelog.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +"""Regenerate CHANGELOG.md from git history using the rules in cliff.toml.""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +CONFIG = Path(__file__).resolve().parents[1] / "cliff.toml" + + +def git_cliff_executable() -> str: + """Locate the git-cliff binary. + + git-cliff ships as a dev dependency, so it normally sits next to the + running interpreter. The PATH is searched as a fallback for the case + where this script is run with an interpreter from outside that + environment. + + Returns: + The path to the git-cliff binary. + + Raises: + SystemExit: If no git-cliff binary can be found. + """ + candidate = Path(sys.executable).parent / "git-cliff" + for path in (candidate, candidate.with_suffix(".exe")): + if path.exists(): + return str(path) + + found = shutil.which("git-cliff") + if found is None: + raise SystemExit( + "error: git-cliff not found; install the dev extra with " + "'uv sync --extra dev'" + ) + return found + + +def build_command(argv: list[str]) -> list[str]: + """Build the git-cliff command for the given passthrough arguments. + + Args: + argv: Arguments forwarded verbatim to git-cliff. + + Returns: + The command as an argument list. + """ + return [git_cliff_executable(), "--config", str(CONFIG), *argv] + + +def main() -> int: + """Run git-cliff and return its exit status.""" + return subprocess.run(build_command(sys.argv[1:]), check=False).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release.py b/scripts/release.py new file mode 100755 index 0000000..685bd0c --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +"""Prepare a release: bump the version, changelog, commit, and tag. + +The release workflow at ``.github/workflows/release.yaml`` picks up the +pushed tag and publishes a GitHub Release with the matching CHANGELOG +section. + +Usage: + scripts/release.py X.Y.Z # bump + commit + tag (no push) + scripts/release.py X.Y.Z --push # also push branch and tag + scripts/release.py X.Y.Z --dry-run # show what would happen + scripts/release.py X.Y.Z --no-tag # commit only, skip the tag +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +from collections.abc import Callable +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+(?:[.-][0-9A-Za-z.-]+)?$") +VERSION_LINE = re.compile(r'^version\s*=\s*"[^"]*"', re.MULTILINE) + +Runner = Callable[..., subprocess.CompletedProcess[str]] + + +def run( + command: list[str], + *, + check: bool = True, + capture: bool = False, +) -> subprocess.CompletedProcess[str]: + """Run a command, optionally capturing its output. + + Args: + command: The command and its arguments. + check: Raise on a non-zero exit status. + capture: Capture stdout and stderr as text. + + Returns: + The completed process. + """ + return subprocess.run( + command, + check=check, + capture_output=capture, + text=True, + ) + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser() + parser.add_argument("version", help="Release version, as X.Y.Z.") + parser.add_argument( + "--push", + action="store_true", + help="Push the release commit and tag to origin.", + ) + parser.add_argument( + "--no-tag", + action="store_true", + help="Create the release commit without an annotated tag.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the planned steps without modifying anything.", + ) + return parser.parse_args() + + +def validate_version(version: str) -> str: + """Validate a release version string. + + Args: + version: Candidate version. + + Returns: + The version unchanged. + + Raises: + ValueError: If the version is not of the form X.Y.Z. + """ + if not VERSION_PATTERN.fullmatch(version): + raise ValueError( + f"'{version}' is not a valid version (expected X.Y.Z)." + ) + return version + + +def tag_exists(tag: str, *, runner: Runner = run) -> bool: + """Return whether a git tag already exists. + + Args: + tag: Tag name to look for. + runner: Command runner, injectable for tests. + + Returns: + True if the tag resolves. + """ + result = runner( + ["git", "rev-parse", "--verify", f"refs/tags/{tag}"], + check=False, + capture=True, + ) + return result.returncode == 0 + + +def working_tree_dirty(*, runner: Runner = run) -> bool: + """Return whether the working tree has uncommitted changes. + + Args: + runner: Command runner, injectable for tests. + + Returns: + True if tracked files differ from HEAD. + """ + result = runner( + ["git", "status", "--porcelain", "--untracked-files=no"], + capture=True, + ) + return bool(result.stdout.strip()) + + +def previous_tag(*, runner: Runner = run) -> str | None: + """Return the most recent version tag reachable from HEAD. + + Tags on unrelated branches are skipped automatically. + + Args: + runner: Command runner, injectable for tests. + + Returns: + The tag name, or None when no version tag is reachable. + """ + result = runner( + [ + "git", + "describe", + "--tags", + "--abbrev=0", + "--match=v[0-9]*", + "HEAD", + ], + check=False, + capture=True, + ) + if result.returncode != 0: + return None + return result.stdout.strip() or None + + +def changelog_range(prev: str | None) -> list[str]: + """Return the git-cliff commit range arguments. + + Args: + prev: The previous release tag, if any. + + Returns: + A single-element range list, or an empty list for full history. + """ + if prev is None: + return [] + return [f"{prev}..HEAD"] + + +def bump_pyproject(path: Path, version: str) -> None: + """Rewrite the project version in pyproject.toml. + + Args: + path: Path to pyproject.toml. + version: New version string. + + Raises: + ValueError: If no version line is present. + """ + content = path.read_text(encoding="utf-8") + updated, count = VERSION_LINE.subn( + f'version = "{version}"', + content, + count=1, + ) + if count == 0: + raise ValueError(f"No version line found in {path}.") + path.write_text(updated, encoding="utf-8") + + +def sync_lockfile(*, runner: Runner = run) -> None: + """Refresh uv.lock for the new version and verify it is in sync. + + uv.lock pins the project's own version, and CI runs with ``--locked``, + so the lockfile must be regenerated alongside pyproject.toml. + + Args: + runner: Command runner, injectable for tests. + """ + runner(["uv", "lock"]) + runner(["uv", "lock", "--check"]) + + +def generate_changelog( + tag: str, + prev: str | None, + *, + runner: Runner = run, +) -> None: + """Write the new release section into CHANGELOG.md. + + With no previous tag the file is regenerated with ``-o``, because + git-cliff refuses ``--prepend`` unless a range, ``-u``, or ``-l`` is + given, and because only a full render emits the ``# Changelog`` + header. Once one release exists the file starts with that header + followed by a blank line, which git-cliff recognises, so later + releases are prepended above the previous section. + + Args: + tag: The release tag, including the leading 'v'. + prev: The previous release tag, if any. + runner: Command runner, injectable for tests. + """ + script = str(REPO_ROOT / "scripts" / "gen_changelog.py") + if prev is None: + runner([sys.executable, script, "--tag", tag, "-o", "CHANGELOG.md"]) + return + runner( + [ + sys.executable, + script, + "--tag", + tag, + *changelog_range(prev), + "--prepend", + "CHANGELOG.md", + ] + ) + + +def main() -> int: + """Run the release preparation process.""" + args = parse_args() + # git, git-cliff, and uv all resolve paths against the current + # directory. Anchoring to the repository root keeps a release invoked + # from a subdirectory from writing the changelog somewhere else and + # then aborting with the version bump already applied. + os.chdir(REPO_ROOT) + version = validate_version(args.version) + tag = f"v{version}" + + if tag_exists(tag): + raise SystemExit(f"error: tag {tag} already exists") + + prev = previous_tag() + if prev is None: + print( + "warning: no previous tag reachable; the changelog will cover " + "all history", + file=sys.stderr, + ) + else: + print(f"Previous reachable tag: {prev}") + + if args.dry_run: + print(f"[dry-run] bump pyproject.toml to {version}") + print("[dry-run] uv lock && uv lock --check") + print( + "[dry-run] prepend CHANGELOG.md " + f"({prev + '..HEAD' if prev else 'full history'})" + ) + print(f"[dry-run] commit: chore(release): {tag}") + if not args.no_tag: + print(f"[dry-run] create annotated tag {tag}") + if args.push: + print("[dry-run] push branch and tag") + return 0 + + if working_tree_dirty(): + raise SystemExit( + "error: working tree has uncommitted changes; commit or stash " + "them first" + ) + + print(f"Preparing release: {tag}") + bump_pyproject(REPO_ROOT / "pyproject.toml", version) + sync_lockfile() + generate_changelog(tag, prev) + + run(["git", "add", "pyproject.toml", "uv.lock", "CHANGELOG.md"]) + run(["git", "commit", "-m", f"chore(release): {tag}"]) + + if not args.no_tag: + run(["git", "tag", "-a", tag, "-m", tag]) + + if args.push: + run(["git", "push", "origin", "HEAD", "--follow-tags"]) + print(f"\nPushed {tag}. The release workflow will publish it.") + else: + print(f"\nCreated commit and tag {tag} locally.") + print("Push with: git push origin HEAD --follow-tags") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/update_coverage_readme.py b/scripts/update_coverage_readme.py index 68e6ee3..6f7f0c1 100755 --- a/scripts/update_coverage_readme.py +++ b/scripts/update_coverage_readme.py @@ -1,4 +1,9 @@ #!/usr/bin/env python3 + +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + """Update the README coverage block from coverage.py XML output.""" from __future__ import annotations diff --git a/scripts/validate_distribution.py b/scripts/validate_distribution.py index e76ded9..d1eece5 100755 --- a/scripts/validate_distribution.py +++ b/scripts/validate_distribution.py @@ -1,4 +1,9 @@ #!/usr/bin/env python3 + +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + """Build the project distribution and verify the wheel imports cleanly.""" from __future__ import annotations diff --git a/tests/test_add_spdx_headers.py b/tests/test_add_spdx_headers.py new file mode 100644 index 0000000..e2e12c6 --- /dev/null +++ b/tests/test_add_spdx_headers.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +import sys +from pathlib import Path + +from scripts.add_spdx_headers import annotatable, build_command + + +def test_annotatable_keeps_non_empty_files(tmp_path: Path) -> None: + populated = tmp_path / "populated.py" + populated.write_text("x = 1\n", encoding="utf-8") + + assert annotatable([str(populated)]) == [str(populated)] + + +def test_annotatable_skips_zero_byte_files(tmp_path: Path) -> None: + empty = tmp_path / "__init__.py" + empty.touch() + + assert annotatable([str(empty)]) == [] + + +def test_annotatable_skips_missing_paths(tmp_path: Path) -> None: + assert annotatable([str(tmp_path / "absent.py")]) == [] + + +def test_annotatable_skips_directories(tmp_path: Path) -> None: + assert annotatable([str(tmp_path)]) == [] + + +def test_build_command_uses_reuse_annotate_with_skip_flags() -> None: + command = build_command( + ["pkg/mod.py"], + year="2026", + copyright_holder="the Python Template contributors", + license_id="BSD-2-Clause", + ) + + assert command[:4] == [sys.executable, "-m", "reuse", "annotate"] + assert "--skip-existing" in command + assert "--merge-copyrights" in command + assert "--skip-unrecognised" in command + assert command[-1] == "pkg/mod.py" + + +def test_build_command_passes_copyright_metadata() -> None: + command = build_command( + ["pkg/mod.py"], + year="2026", + copyright_holder="the Acme contributors", + license_id="BSD-2-Clause", + ) + + assert command[command.index("--year") + 1] == "2026" + assert command[command.index("--copyright") + 1] == "the Acme contributors" + assert command[command.index("--license") + 1] == "BSD-2-Clause" diff --git a/tests/test_bootstrap_template.py b/tests/test_bootstrap_template.py index 84d8f84..2a07b5f 100644 --- a/tests/test_bootstrap_template.py +++ b/tests/test_bootstrap_template.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + import subprocess import sys from pathlib import Path @@ -5,11 +9,13 @@ import pytest from scripts.bootstrap_template import ( + PLACEHOLDER_COPYRIGHT, format_tox_env, normalize_distribution_name, normalize_minimum_python_version, parse_args, resolve_metadata, + update_spdx_copyright, versions_from_minimum, ) @@ -396,3 +402,115 @@ def test_bootstrap_template_updates_minimum_python_version( assert "project_name" not in docker_ci assert 'name = "demo-service"' in uv_lock assert f'requires-python = ">={minimum_python_version}, <4"' in uv_lock + + +# These fixtures quote SPDX tags as data. The markers stop `reuse lint` from +# parsing them as annotations on this file. +# REUSE-IgnoreStart +SPDX_HEADER = ( + "# SPDX-FileCopyrightText: 2026 the Python Template contributors\n" + "#\n" + "# SPDX-License-Identifier: BSD-2-Clause\n" + "\n" + '"""Module."""\n' +) +REUSE_TOML = ( + "version = 1\n" + "\n" + "[[annotations]]\n" + 'path = ["README.md"]\n' + 'precedence = "aggregate"\n' + 'SPDX-FileCopyrightText = "2026 the Python Template contributors"\n' + 'SPDX-License-Identifier = "BSD-2-Clause"\n' +) +# REUSE-IgnoreEnd + + +def test_update_spdx_copyright_rewrites_inline_headers(tmp_path: Path) -> None: + module = tmp_path / "mod.py" + module.write_text(SPDX_HEADER, encoding="utf-8") + + update_spdx_copyright( + [module], + project_title="Acme Tool", + dry_run=False, + ) + + content = module.read_text(encoding="utf-8") + assert "2026 the Acme Tool contributors" in content + assert PLACEHOLDER_COPYRIGHT not in content + + +def test_update_spdx_copyright_rewrites_reuse_toml(tmp_path: Path) -> None: + reuse_toml = tmp_path / "REUSE.toml" + reuse_toml.write_text(REUSE_TOML, encoding="utf-8") + + update_spdx_copyright( + [reuse_toml], + project_title="Acme Tool", + dry_run=False, + ) + + content = reuse_toml.read_text(encoding="utf-8") + assert ( + 'SPDX-FileCopyrightText = "2026 the Acme Tool contributors"' in content + ) + + +def test_update_spdx_copyright_preserves_license_identifier( + tmp_path: Path, +) -> None: + module = tmp_path / "mod.py" + module.write_text(SPDX_HEADER, encoding="utf-8") + + update_spdx_copyright( + [module], + project_title="Acme Tool", + dry_run=False, + ) + + content = module.read_text(encoding="utf-8") + # REUSE-IgnoreStart + assert "# SPDX-License-Identifier: BSD-2-Clause" in content + # REUSE-IgnoreEnd + + +def test_update_spdx_copyright_dry_run_leaves_files_untouched( + tmp_path: Path, +) -> None: + module = tmp_path / "mod.py" + module.write_text(SPDX_HEADER, encoding="utf-8") + + update_spdx_copyright( + [module], + project_title="Acme Tool", + dry_run=True, + ) + + assert module.read_text(encoding="utf-8") == SPDX_HEADER + + +def test_update_spdx_copyright_ignores_missing_files(tmp_path: Path) -> None: + update_spdx_copyright( + [tmp_path / "absent.py"], + project_title="Acme Tool", + dry_run=False, + ) + + +def test_release_workflow_is_a_bootstrap_target() -> None: + from scripts.bootstrap_template import WORKFLOW_FILES + + assert Path(".github/workflows/release.yaml") in WORKFLOW_FILES + + +def test_spdx_files_cover_the_new_automation_scripts() -> None: + from scripts.bootstrap_template import SPDX_FILES + + for path in ( + Path("REUSE.toml"), + Path("scripts/release.py"), + Path("scripts/gen_changelog.py"), + Path("scripts/add_spdx_headers.py"), + ): + assert path in SPDX_FILES diff --git a/tests/test_check_yaml_sexagesimal.py b/tests/test_check_yaml_sexagesimal.py new file mode 100644 index 0000000..f65cea3 --- /dev/null +++ b/tests/test_check_yaml_sexagesimal.py @@ -0,0 +1,106 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from scripts.check_yaml_sexagesimal import ( + bare_sexagesimal_values, + main, + sexagesimal_to_int, +) + +if TYPE_CHECKING: + from pathlib import Path + +DEPENDABOT = """version: 2 +updates: +- package-ecosystem: uv + directory: / + schedule: + interval: weekly + day: monday + time: 06:00 + timezone: Europe/Rome +""" + + +@pytest.mark.parametrize( + ("value", "expected"), + [("06:00", 21600), ("06:30", 23400), ("07:00", 25200), ("1:02:03", 3723)], +) +def test_sexagesimal_to_int_matches_the_ruby_result( + value: str, expected: int +) -> None: + assert sexagesimal_to_int(value) == expected + + +def test_bare_sexagesimal_values_flags_an_unquoted_time() -> None: + assert bare_sexagesimal_values(DEPENDABOT) == [(8, "06:00")] + + +def test_bare_sexagesimal_values_accepts_a_quoted_time() -> None: + assert bare_sexagesimal_values(DEPENDABOT.replace("06:00", "'06:00'")) == [] + + +def test_bare_sexagesimal_values_flags_a_sequence_item() -> None: + assert bare_sexagesimal_values("times:\n- 06:00\n") == [(2, "06:00")] + + +def test_bare_sexagesimal_values_reports_every_line() -> None: + content = "a: 06:00\nb: 06:30\nc: '07:00'\n" + + assert bare_sexagesimal_values(content) == [(1, "06:00"), (2, "06:30")] + + +@pytest.mark.parametrize( + "content", + [ + "version: 2\n", + "timeout-minutes: 20\n", + "timezone: Europe/Rome\n", + "python-version: '3.10'\n", + "cron: '0 6 * * 1'\n", + "url: http://example.com:8080\n", + "# time: 06:00\n", + "schedule:\n", + # 60 is out of range, so a YAML 1.1 parser leaves it a string. + "time: 06:60\n", + ], +) +def test_bare_sexagesimal_values_ignores_unambiguous_scalars( + content: str, +) -> None: + assert bare_sexagesimal_values(content) == [] + + +def test_bare_sexagesimal_values_ignores_a_trailing_comment() -> None: + assert bare_sexagesimal_values("time: '06:00' # daily\n") == [] + + +def test_main_reports_the_offending_file_and_line( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + path = tmp_path / "dependabot.yml" + path.write_text(DEPENDABOT, encoding="utf-8") + + assert main([str(path)]) == 1 + + output = capsys.readouterr().out + assert f"{path}:8" in output + assert "21600" in output + assert "'06:00'" in output + + +def test_main_accepts_a_quoted_file(tmp_path: Path) -> None: + path = tmp_path / "dependabot.yml" + path.write_text( + DEPENDABOT.replace("06:00", "'06:00'"), + encoding="utf-8", + ) + + assert main([str(path)]) == 0 diff --git a/tests/test_gen_changelog.py b/tests/test_gen_changelog.py new file mode 100644 index 0000000..0e276b6 --- /dev/null +++ b/tests/test_gen_changelog.py @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +import pytest + +from scripts.gen_changelog import CONFIG, build_command, git_cliff_executable + +if TYPE_CHECKING: + from pathlib import Path + + +def test_build_command_passes_the_config_and_forwards_arguments() -> None: + command = build_command(["--tag", "v0.1.0", "--prepend", "CHANGELOG.md"]) + + assert command[1:] == [ + "--config", + str(CONFIG), + "--tag", + "v0.1.0", + "--prepend", + "CHANGELOG.md", + ] + + +def test_git_cliff_executable_prefers_the_interpreter_environment( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + binary = tmp_path / "git-cliff" + binary.touch() + monkeypatch.setattr(sys, "executable", str(tmp_path / "python")) + + assert git_cliff_executable() == str(binary) + + +def test_git_cliff_executable_finds_the_windows_binary( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + binary = tmp_path / "git-cliff.exe" + binary.touch() + monkeypatch.setattr(sys, "executable", str(tmp_path / "python.exe")) + + assert git_cliff_executable() == str(binary) + + +def test_git_cliff_executable_falls_back_to_the_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + binary = tmp_path / "elsewhere" / "git-cliff" + binary.parent.mkdir() + binary.touch() + binary.chmod(0o755) + monkeypatch.setattr(sys, "executable", str(tmp_path / "python")) + monkeypatch.setenv("PATH", str(binary.parent)) + + assert git_cliff_executable() == str(binary) + + +def test_git_cliff_executable_reports_a_missing_binary( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(sys, "executable", str(tmp_path / "python")) + monkeypatch.setenv("PATH", str(tmp_path / "empty")) + + with pytest.raises(SystemExit, match="git-cliff"): + git_cliff_executable() diff --git a/tests/test_greeter.py b/tests/test_greeter.py index 24763f3..ff37c00 100644 --- a/tests/test_greeter.py +++ b/tests/test_greeter.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + import pytest from project_name.greeter import Greeter, Language, get_language diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..c096601 --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,229 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +from scripts.release import ( + REPO_ROOT, + bump_pyproject, + changelog_range, + generate_changelog, + main, + previous_tag, + sync_lockfile, + tag_exists, + validate_version, + working_tree_dirty, +) + +PYPROJECT = """[project] +name = "project_name" +version = "0.0.0" +description = "A simple template project." +""" + + +class FakeRunner: + """Records commands and replays canned results.""" + + def __init__( + self, + results: dict[str, subprocess.CompletedProcess[str]] | None = None, + ) -> None: + self.commands: list[list[str]] = [] + self.results = results or {} + + def __call__( + self, + command: list[str], + *, + check: bool = True, + capture: bool = False, + ) -> subprocess.CompletedProcess[str]: + self.commands.append(command) + key = " ".join(command) + result = self.results.get(key) + if result is None: + return subprocess.CompletedProcess(command, 0, "", "") + return result + + +REPO_CONFIG = { + # Written to the repo config rather than passed per command, so callers + # can commit and tag without a global git identity. + "user.email": "a@b", + "user.name": "a", + # Ambient signing settings would otherwise make commits and tags here + # depend on the contributor's keys being present and unlocked. + "commit.gpgsign": "false", + "tag.gpgSign": "false", +} + + +def git_repo(tmp_path: Path) -> Path: + """Create a throwaway git repo with one commit. + + The repo is configured to be independent of the ambient git config, so + the tests behave the same for every contributor and on CI. + """ + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + for key, value in REPO_CONFIG.items(): + subprocess.run(["git", "config", key, value], cwd=tmp_path, check=True) + (tmp_path / "file.txt").write_text("hello\n", encoding="utf-8") + subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "-qm", "init"], cwd=tmp_path, check=True) + return tmp_path + + +@pytest.mark.parametrize("version", ["0.1.0", "1.2.3", "10.0.1", "1.0.0-rc1"]) +def test_validate_version_accepts_valid_versions(version: str) -> None: + assert validate_version(version) == version + + +@pytest.mark.parametrize("version", ["1.2", "v1.2.3", "abc", "", "1.2.3-"]) +def test_validate_version_rejects_invalid_versions(version: str) -> None: + with pytest.raises(ValueError): + validate_version(version) + + +def test_bump_pyproject_replaces_version(tmp_path: Path) -> None: + path = tmp_path / "pyproject.toml" + path.write_text(PYPROJECT, encoding="utf-8") + + bump_pyproject(path, "0.1.0") + + assert 'version = "0.1.0"' in path.read_text(encoding="utf-8") + assert 'version = "0.0.0"' not in path.read_text(encoding="utf-8") + + +def test_bump_pyproject_leaves_other_metadata_alone(tmp_path: Path) -> None: + path = tmp_path / "pyproject.toml" + path.write_text(PYPROJECT, encoding="utf-8") + + bump_pyproject(path, "0.1.0") + + content = path.read_text(encoding="utf-8") + assert 'name = "project_name"' in content + assert 'description = "A simple template project."' in content + + +def test_bump_pyproject_raises_without_a_version_line(tmp_path: Path) -> None: + path = tmp_path / "pyproject.toml" + path.write_text('[project]\nname = "x"\n', encoding="utf-8") + + with pytest.raises(ValueError): + bump_pyproject(path, "0.1.0") + + +def test_tag_exists_is_false_for_unknown_tag( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(git_repo(tmp_path)) + + assert tag_exists("v9.9.9") is False + + +def test_tag_exists_is_true_for_created_tag( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo = git_repo(tmp_path) + subprocess.run( + ["git", "tag", "-a", "v0.1.0", "-m", "v0.1.0"], cwd=repo, check=True + ) + monkeypatch.chdir(repo) + + assert tag_exists("v0.1.0") is True + + +def test_working_tree_dirty_is_false_when_clean( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(git_repo(tmp_path)) + + assert working_tree_dirty() is False + + +def test_working_tree_dirty_is_true_with_modifications( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo = git_repo(tmp_path) + (repo / "file.txt").write_text("changed\n", encoding="utf-8") + monkeypatch.chdir(repo) + + assert working_tree_dirty() is True + + +def test_previous_tag_is_none_without_tags( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(git_repo(tmp_path)) + + assert previous_tag() is None + + +def test_previous_tag_returns_most_recent_reachable_tag( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo = git_repo(tmp_path) + subprocess.run( + ["git", "tag", "-a", "v0.1.0", "-m", "v0.1.0"], cwd=repo, check=True + ) + monkeypatch.chdir(repo) + + assert previous_tag() == "v0.1.0" + + +def test_main_operates_from_the_repository_root( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The release runs against the repo regardless of the caller's cwd.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "argv", ["release.py", "99.0.0", "--dry-run"]) + + assert main() == 0 + assert Path.cwd() == REPO_ROOT + + +def test_sync_lockfile_locks_then_verifies() -> None: + runner = FakeRunner() + + sync_lockfile(runner=runner) + + assert runner.commands == [["uv", "lock"], ["uv", "lock", "--check"]] + + +def test_changelog_range_is_empty_without_a_previous_tag() -> None: + assert changelog_range(None) == [] + + +def test_changelog_range_spans_from_previous_tag_to_head() -> None: + assert changelog_range("v0.1.0") == ["v0.1.0..HEAD"] + + +def test_generate_changelog_regenerates_when_there_is_no_previous_tag() -> None: + runner = FakeRunner() + + generate_changelog("v0.1.0", None, runner=runner) + + command = runner.commands[0] + assert "-o" in command + assert "--prepend" not in command + assert command[command.index("-o") + 1] == "CHANGELOG.md" + + +def test_generate_changelog_prepends_when_a_previous_tag_exists() -> None: + runner = FakeRunner() + + generate_changelog("v0.1.0", "v0.0.9", runner=runner) + + command = runner.commands[0] + assert "--prepend" in command + assert "-o" not in command + assert "v0.0.9..HEAD" in command diff --git a/tests/test_template_smoke.py b/tests/test_template_smoke.py index f832807..3b9eb8e 100644 --- a/tests/test_template_smoke.py +++ b/tests/test_template_smoke.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + import os import shutil import subprocess @@ -26,10 +30,13 @@ ".github/workflows/documentation.yaml", ".github/workflows/package.yaml", ".github/workflows/quality.yaml", + ".github/workflows/release.yaml", ".github/workflows/security.yaml", ".github/workflows/template-smoke.yaml", ".github/workflows/tests.yaml", "README.md", + "REUSE.toml", + "cliff.toml", "docs/api.md", "docs/conf.py", "docs/documentation.md", @@ -37,6 +44,9 @@ "docs/template.md", "docs/usage.md", "pyproject.toml", + "scripts/add_spdx_headers.py", + "scripts/gen_changelog.py", + "scripts/release.py", "tox.ini", ) @@ -120,3 +130,22 @@ def test_generated_project_bootstraps_and_builds(tmp_path: Path) -> None: content = (generated_repo / relative_path).read_text(encoding="utf-8") assert "project_name" not in content, relative_path assert "python-template" not in content, relative_path + + reuse_toml = (generated_repo / "REUSE.toml").read_text(encoding="utf-8") + assert "the Demo Service contributors" in reuse_toml + assert "the Python Template contributors" not in reuse_toml + + package_modules = sorted( + path + for path in (generated_repo / "demo_service").glob("*.py") + if path.stat().st_size > 0 + ) + assert package_modules + + for module in package_modules: + content = module.read_text(encoding="utf-8") + # REUSE-IgnoreStart + assert "SPDX-License-Identifier: BSD-2-Clause" in content, str(module) + # REUSE-IgnoreEnd + assert "the Demo Service contributors" in content, str(module) + assert "the Python Template contributors" not in content, str(module) diff --git a/tox.ini b/tox.ini index eccdf06..e9ad31b 100644 --- a/tox.ini +++ b/tox.ini @@ -58,6 +58,25 @@ extras = dev commands = {envpython} scripts/validate_distribution.py --package project_name --dist-dir dist +[testenv:changelog] +description = generate the changelog from git history +runner = uv-venv-lock-runner +extras = dev +allowlist_externals = + git +commands = + {envpython} scripts/gen_changelog.py {posargs} + +[testenv:release] +description = prepare a release commit and tag +runner = uv-venv-lock-runner +extras = dev +allowlist_externals = + git + uv +commands = + {envpython} scripts/release.py {posargs} + [testenv:docs] description = build the documentation site runner = uv-venv-lock-runner diff --git a/uv.lock b/uv.lock index d0d889d..631763e 100644 --- a/uv.lock +++ b/uv.lock @@ -241,14 +241,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.1" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] @@ -459,6 +459,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f", size = 339262, upload-time = "2025-12-19T17:34:38.905Z" }, ] +[[package]] +name = "git-cliff" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/57/b12494e2cbc3c9154c942e64659b5aec2b1ce9f12d07f6dc6167e2c63ae5/git_cliff-2.13.1.tar.gz", hash = "sha256:e949ea9c3951ba6037b99eec465162be2584f27f0836ace45f44d6f45650f8c6", size = 113119, upload-time = "2026-04-26T10:33:42.331Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/dd/24768c3c0030710d36706c17b997d06aee27cb76b27ab2abb058ae254175/git_cliff-2.13.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:08a9cb0ec760e165210ed22fefa295b6549a3520b420db995ccbb3620cbb1fbe", size = 7260035, upload-time = "2026-04-26T10:33:16.269Z" }, + { url = "https://files.pythonhosted.org/packages/22/df/842973ead79d27a58cd1eccd167191c0a71513e5c1e9dc30337dafdb7d36/git_cliff-2.13.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e92cf470ecbe73f7d2963dfa80e8961a6b76888d0c19949b0f028a28a0a0470c", size = 6854384, upload-time = "2026-04-26T10:33:18.808Z" }, + { url = "https://files.pythonhosted.org/packages/7c/4d/6d6efa7d61be8632563990ccd402e401695e4a85a0bb1002f28730d03268/git_cliff-2.13.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e8d8e420adf6a36b97e0fbdbf2b07e47712199a9baf3675c9a759430243ea26", size = 7308164, upload-time = "2026-04-26T10:33:20.869Z" }, + { url = "https://files.pythonhosted.org/packages/f0/4a/98b8d2f53a2d0b7d313e98ab363ad7e0d6a514e878a8d678f22402cb0ec7/git_cliff-2.13.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ab059d671565189faa4f3858b2fb42535aa39fe265ad95d84d5c116a2724fc7", size = 7687163, upload-time = "2026-04-26T10:33:23.096Z" }, + { url = "https://files.pythonhosted.org/packages/c7/07/cdd149b3909644aa3f0be7960406d9bbb38598f8618e039ac48cbb43ded6/git_cliff-2.13.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a93db30da45967c42df607fbbc2092111fcd576b0ca9e2fbddd3f653d8c71be7", size = 7317670, upload-time = "2026-04-26T10:33:25.323Z" }, + { url = "https://files.pythonhosted.org/packages/2e/11/6d377a7f3113f6e26d87a28a32013eb05bd62bce176d6f8ee808e4868c1f/git_cliff-2.13.1-py3-none-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:df9f5a2bd16e5225030c9c2362e6ac70b34a906c0fbb83f8cbf5ae46932ba0d2", size = 7502294, upload-time = "2026-04-26T10:33:27.315Z" }, + { url = "https://files.pythonhosted.org/packages/80/6b/e1da9acf3aec99e6600be02b6ce0c9e8bd42d072e3120384514c905231e2/git_cliff-2.13.1-py3-none-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c12276784d280aa6a7148d3e52ff139e891f4c97720cd9be581b19892ea39fe0", size = 7927258, upload-time = "2026-04-26T10:33:29.554Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ea/9f2188a5e474e5f02193d9c1cdf7028773d483a3f508a1df4f1d93c9cc80/git_cliff-2.13.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:17da93dc605cbc48c762770402067fa726437cedbd62514f9caef6e0ccb58a43", size = 7308153, upload-time = "2026-04-26T10:33:31.589Z" }, + { url = "https://files.pythonhosted.org/packages/b3/70/5e2b2a0e42c07956f911e1eccd6dc6d79b96fc6c8a604a526c1ee0474c84/git_cliff-2.13.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cd4a08cf3f638ec71d2ed451aa8673bef99e1107a36366153db97ca18d981655", size = 7502287, upload-time = "2026-04-26T10:33:33.894Z" }, + { url = "https://files.pythonhosted.org/packages/05/50/cc4c1d3d360621c0235d66d2c74472d9993d8aadf23ffa311eaf29d7a3aa/git_cliff-2.13.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c5bb87f6e1db18be09e50c9d59234dc949713e20c2700e94eca378d22ad79719", size = 7927253, upload-time = "2026-04-26T10:33:36.205Z" }, + { url = "https://files.pythonhosted.org/packages/86/5d/717d30f37dad65a6cc5220b04a3ef1bc44c31fb56d0ce2895114e159df4f/git_cliff-2.13.1-py3-none-win32.whl", hash = "sha256:c8878972e0a6c26d9137fc406a611116239333578d95ac05064d2807920bd83c", size = 6718261, upload-time = "2026-04-26T10:33:38.1Z" }, + { url = "https://files.pythonhosted.org/packages/99/b2/99fac50978b9a90bfec0f1b89354a667ec83f4990301f6c708abce05484e/git_cliff-2.13.1-py3-none-win_amd64.whl", hash = "sha256:856d831a0bede9c258229dbd4d4c2b1c0810d8fce3d3882729669e8dc09c72bf", size = 7714969, upload-time = "2026-04-26T10:33:40.163Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -701,63 +721,75 @@ wheels = [ [[package]] name = "msgpack" -version = "1.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/a2/3b68a9e769db68668b25c6108444a35f9bd163bb848c0650d516761a59c0/msgpack-1.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0051fffef5a37ca2cd16978ae4f0aef92f164df86823871b5162812bebecd8e2", size = 81318, upload-time = "2025-10-08T09:14:38.722Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e1/2b720cc341325c00be44e1ed59e7cfeae2678329fbf5aa68f5bda57fe728/msgpack-1.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a605409040f2da88676e9c9e5853b3449ba8011973616189ea5ee55ddbc5bc87", size = 83786, upload-time = "2025-10-08T09:14:40.082Z" }, - { url = "https://files.pythonhosted.org/packages/71/e5/c2241de64bfceac456b140737812a2ab310b10538a7b34a1d393b748e095/msgpack-1.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b696e83c9f1532b4af884045ba7f3aa741a63b2bc22617293a2c6a7c645f251", size = 398240, upload-time = "2025-10-08T09:14:41.151Z" }, - { url = "https://files.pythonhosted.org/packages/b7/09/2a06956383c0fdebaef5aa9246e2356776f12ea6f2a44bd1368abf0e46c4/msgpack-1.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365c0bbe981a27d8932da71af63ef86acc59ed5c01ad929e09a0b88c6294e28a", size = 406070, upload-time = "2025-10-08T09:14:42.821Z" }, - { url = "https://files.pythonhosted.org/packages/0e/74/2957703f0e1ef20637d6aead4fbb314330c26f39aa046b348c7edcf6ca6b/msgpack-1.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41d1a5d875680166d3ac5c38573896453bbbea7092936d2e107214daf43b1d4f", size = 393403, upload-time = "2025-10-08T09:14:44.38Z" }, - { url = "https://files.pythonhosted.org/packages/a5/09/3bfc12aa90f77b37322fc33e7a8a7c29ba7c8edeadfa27664451801b9860/msgpack-1.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354e81bcdebaab427c3df4281187edc765d5d76bfb3a7c125af9da7a27e8458f", size = 398947, upload-time = "2025-10-08T09:14:45.56Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4f/05fcebd3b4977cb3d840f7ef6b77c51f8582086de5e642f3fefee35c86fc/msgpack-1.1.2-cp310-cp310-win32.whl", hash = "sha256:e64c8d2f5e5d5fda7b842f55dec6133260ea8f53c4257d64494c534f306bf7a9", size = 64769, upload-time = "2025-10-08T09:14:47.334Z" }, - { url = "https://files.pythonhosted.org/packages/d0/3e/b4547e3a34210956382eed1c85935fff7e0f9b98be3106b3745d7dec9c5e/msgpack-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:db6192777d943bdaaafb6ba66d44bf65aa0e9c5616fa1d2da9bb08828c6b39aa", size = 71293, upload-time = "2025-10-08T09:14:48.665Z" }, - { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, - { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" }, - { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" }, - { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" }, - { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" }, - { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" }, - { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, - { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, - { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, - { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, - { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, - { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, - { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, - { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, - { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, - { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, - { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, - { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, - { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, - { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, - { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, - { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, - { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, - { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, - { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, - { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, - { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, - { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, - { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, - { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, - { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/16/f70100614b69feb3ade7285f08c9c52d6cda0a5c03f3f5e2facd63acb211/msgpack-1.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c7b398c56ff125feae96c2737abfec5595f1fa0aa186df60c56040b8accb95c", size = 82926, upload-time = "2026-06-18T16:12:31.531Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3c/08ecd5cdfe4e2de43aec79062028ad0f7b2d9b1fea5430068c198ba570da/msgpack-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1548006a91aa93c5da81f3bdcebc1a0d10cea2d25969754fbe848da622b2b895", size = 82730, upload-time = "2026-06-18T16:12:32.894Z" }, + { url = "https://files.pythonhosted.org/packages/19/9f/a70c9cb1a04ecc134005149367dcfe35d167284e8f65035a1e4156ad17b5/msgpack-1.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dabedcd0f23559f3596428c6589c1cd8c6eaed3a0d720795b07b0225d769203", size = 400729, upload-time = "2026-06-18T16:12:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7f/5ce020168cf0439041526e95aa068c722c016aee21624e331aeabeee2e8e/msgpack-1.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83efa1c898e0fc5380fc0cabbf75164c52e3b5cbb45973710d75821928380c73", size = 407625, upload-time = "2026-06-18T16:12:35.239Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/fb7668ce0386819303047057aef6fc1da73b584291d9cff82b821744e2ef/msgpack-1.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01e2dd6c9b19d333a00282330cc8a73d38d8dabc306dc5b42cd668c3ac82e833", size = 377891, upload-time = "2026-06-18T16:12:36.684Z" }, + { url = "https://files.pythonhosted.org/packages/3d/dc/9ebe654a73c3aed2e40aa6b52e3c2a02b5f53ef0085fa235a45d5b367f87/msgpack-1.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:350cb813d0af6e65d2f7ef0d729f7ff5be5a8bce03665892f43e5883d4ecc1b8", size = 391987, upload-time = "2026-06-18T16:12:37.839Z" }, + { url = "https://files.pythonhosted.org/packages/42/eb/b67cf64218a2fa25e1c671fe1d3dbb06cbeb973e71bc4b822da079862d0b/msgpack-1.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ee1d9ed27d0497b848923746cf762ed2e7db24f4be7eec8e5cbe8c766aa707b7", size = 374603, upload-time = "2026-06-18T16:12:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2e/9ee200cde32fd1a0101b4006202fde554c1860adfb9bf7bff31ea4c08df8/msgpack-1.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:633727297ed063441fd1cda2288865487f33ad14eeb8831afb5f0c396a62cfce", size = 405121, upload-time = "2026-06-18T16:12:40.524Z" }, + { url = "https://files.pythonhosted.org/packages/43/b6/f10117be7ca7a51e8feed699a907b8e663a8cd66e115ae6b4fb30cc7945c/msgpack-1.2.1-cp310-cp310-win32.whl", hash = "sha256:298872ecf9e61950f1c6af4ca969b859ee91783bb920ef6e6172697d0c8aad74", size = 64088, upload-time = "2026-06-18T16:12:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/ba/93/89976c696fb0224662239d952c47b4d1661b34d79a332ef5584facaa8579/msgpack-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:2ff164c1b0bcb740b073b99e945234d0212852fa378e44a208c425379140dbeb", size = 70113, upload-time = "2026-06-18T16:12:42.78Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" }, + { url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" }, + { url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" }, + { url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" }, + { url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" }, + { url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" }, + { url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" }, + { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" }, + { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, + { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, + { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, + { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, + { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, + { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, + { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, ] [[package]] @@ -911,6 +943,7 @@ source = { editable = "." } [package.optional-dependencies] dev = [ { name = "build" }, + { name = "git-cliff" }, { name = "hatchling" }, { name = "lizard" }, { name = "pip-audit" }, @@ -939,6 +972,7 @@ docs = [ requires-dist = [ { name = "build", marker = "extra == 'dev'", specifier = ">=1.3.0,<2" }, { name = "furo", marker = "extra == 'docs'", specifier = ">=2024.8.6,<2027" }, + { name = "git-cliff", marker = "extra == 'dev'", specifier = ">=2.13,<3" }, { name = "hatchling", marker = "extra == 'dev'", specifier = ">=1.29.0,<2" }, { name = "lizard", marker = "extra == 'dev'", specifier = ">=1.22.1,<2" }, { name = "myst-parser", marker = "extra == 'docs'", specifier = ">=3,<5" }, @@ -1476,15 +1510,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.2.1" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/16/dc49e4b9d348b0f0567a67613a946f4e26b3299408ccf78df318c51a2aec/starlette-1.4.0.tar.gz", hash = "sha256:ecf3a067176d63c6412f98c660dab3355b525584d3725c0c40a4519d33ec2130", size = 2708995, upload-time = "2026-08-05T09:36:43.693Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, + { url = "https://files.pythonhosted.org/packages/be/f6/7916cd7717e196bba370c0f17abb30466014ce29094665c3581e51a8c24b/starlette-1.4.0-py3-none-any.whl", hash = "sha256:cacd43738e07b834844e2c8e97b898b40089d74f2678234b0a3e93cd3d515813", size = 74021, upload-time = "2026-08-05T09:36:41.944Z" }, ] [[package]]