diff --git a/.agents/skills/commitizen/SKILL.md b/.agents/skills/commitizen/SKILL.md index 0aa3871b8..c11fcf9db 100644 --- a/.agents/skills/commitizen/SKILL.md +++ b/.agents/skills/commitizen/SKILL.md @@ -2,11 +2,10 @@ name: commitizen description: Use this skill for tasks involving Conventional Commits, commit message validation, Commitizen configuration, semantic version bumps, changelog generation, or CI/release automation with the Commitizen CLI. license: MIT -compatibility: Git repository with Python and Commitizen available as `cz` or runnable from source. Network access is optional and mainly relevant for CI or release integrations. metadata: project: commitizen-tools/commitizen docs: https://commitizen-tools.github.io/commitizen/ - install: "pip install commitizen" + install: "pipx install commitizen" --- # Commitizen @@ -39,31 +38,13 @@ Commitizen is a CLI for enforcing Conventional Commits, automating version bumps ## Important domain details -- Commitizen installs with `pip install commitizen` or `uv add commitizen`. +- Commitizen supports both global installation (recommended for the `cz` CLI) and project-local installation; see the installation section in `docs/README.md` for the full matrix of supported tools. - The default version scheme is PEP 440; `semver` and `semver2` are also supported. - Common version providers include `commitizen`, `pep621`, `poetry`, `cargo`, `npm`, `composer`, `uv`, and `scm`. - `cz changelog` generates Markdown changelogs. - `cz commit` supports `--dry-run` and `--write-message-to-file`. - `cz check` can validate a literal message, a commit-msg file, or a git revision range. -## Suggested references - -- Command docs: - - `docs/commands/commit.md` - - `docs/commands/bump.md` - - `docs/commands/changelog.md` - - `docs/commands/check.md` - - `docs/commands/init.md` -- Config docs: - - `docs/config/configuration_file.md` - - `docs/config/option.md` - - `docs/config/bump.md` -- Automation docs: - - `docs/tutorials/github_actions.md` - - `docs/tutorials/gitlab_ci.md` -- Error handling: - - `docs/exit_codes.md` - ## Examples - Validate one message: `cz check --message "feat(cli): add release command"` diff --git a/.github/workflows/pr-bump-preview.yml b/.github/workflows/pr-bump-preview.yml new file mode 100644 index 000000000..1eab96774 --- /dev/null +++ b/.github/workflows/pr-bump-preview.yml @@ -0,0 +1,94 @@ +name: PR bump preview + +on: + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review] + +permissions: + contents: read + pull-requests: write + +jobs: + bump-preview: + # Skip drafts, and skip fork PRs entirely. `pull_request_target` runs with + # the base repo's GITHUB_TOKEN (write access to PR comments). `cz bump` + # can render Jinja templates from the checked-out workspace whenever + # `update_changelog_on_bump` is set in config, and the renderer is not + # sandboxed (FileSystemLoader('.')) — running it against fork-controlled + # files would risk RCE / token exfiltration. Same-repo PRs are written by + # collaborators who already have push access, so the same risk doesn't + # apply. + if: > + ${{ + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == + github.event.pull_request.base.repo.full_name + }} + runs-on: ubuntu-latest + steps: + - name: Check out PR head + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + fetch-tags: true + # Defense in depth: don't write the workflow token to .git/config. + persist-credentials: false + + - name: Set up Commitizen + uses: commitizen-tools/setup-cz@main + with: + set-git-config: false + + - name: Run cz bump --dry-run + id: dry-run + run: | + set +e + output="$(cz bump --dry-run --yes 2>&1)" + status=$? + set -e + { + echo "status=${status}" + echo "output<<__CZ_BUMP_PREVIEW__" + printf '%s\n' "${output}" + echo "__CZ_BUMP_PREVIEW__" + } >> "$GITHUB_OUTPUT" + + - name: Build comment body + env: + STATUS: ${{ steps.dry-run.outputs.status }} + OUTPUT: ${{ steps.dry-run.outputs.output }} + run: | + { + echo "" + echo "## 🔍 Commitizen bump preview" + echo "" + case "${STATUS}" in + 0) + echo "Merging this PR will produce the following bump:" + echo "" + echo '```' + printf '%s\n' "${OUTPUT}" + echo '```' + ;; + 21) + echo "No commits in this PR are eligible for a version bump." + ;; + *) + echo "⚠️ \`cz bump --dry-run\` exited with status \`${STATUS}\`:" + echo "" + echo '```' + printf '%s\n' "${OUTPUT}" + echo '```' + ;; + esac + } > comment.md + + - name: Post or update PR comment + uses: peter-evans/create-or-update-comment@v5 + with: + token: ${{ secrets.GITHUB_TOKEN }} + issue-number: ${{ github.event.pull_request.number }} + body-path: comment.md + body-includes: "" + edit-mode: replace diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index b22b854da..238bc4db4 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -5,6 +5,15 @@ on: push: tags: - "v*" + # Manual trigger for republishing a specific tag if the original push-on-tag + # run failed and is now too old to be re-run via the GitHub UI (#1790). + # ``ref`` should be a tag name like ``v4.11.1``. + workflow_dispatch: + inputs: + ref: + description: "Tag to republish (e.g., v4.11.1)" + required: true + type: string jobs: deploy: @@ -14,10 +23,19 @@ jobs: id-token: write contents: read steps: + - name: Validate dispatch ref is a tag + if: github.event_name == 'workflow_dispatch' + env: + TAG: ${{ github.event.inputs.ref }} + run: | + if ! git ls-remote --tags "https://github.com/${GITHUB_REPOSITORY}" "refs/tags/${TAG}" | grep -q .; then + echo "::error::Dispatch ref '${TAG}' is not an existing tag" + exit 1 + fi - uses: actions/checkout@v6 with: fetch-depth: 0 - ref: ${{ github.ref_name }} + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', github.event.inputs.ref) || github.ref }} - name: Set up Python uses: astral-sh/setup-uv@v7 - name: Build diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 634c1bedf..7c9d1086f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -56,7 +56,7 @@ repos: - tomli - repo: https://github.com/commitizen-tools/commitizen - rev: v4.16.2 # automatically updated by Commitizen + rev: v4.16.3 # automatically updated by Commitizen hooks: - id: commitizen - id: commitizen-branch diff --git a/commitizen/__version__.py b/commitizen/__version__.py index e2111981b..b2da05759 100644 --- a/commitizen/__version__.py +++ b/commitizen/__version__.py @@ -1 +1 @@ -__version__ = "4.16.2" +__version__ = "4.16.3" diff --git a/commitizen/commands/check.py b/commitizen/commands/check.py index ab5e671d6..fbf707341 100644 --- a/commitizen/commands/check.py +++ b/commitizen/commands/check.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import re import sys from pathlib import Path @@ -40,7 +41,11 @@ def __init__(self, config: BaseConfig, arguments: CheckArgs, *args: object) -> N """ self.commit_msg_file = arguments.get("commit_msg_file") self.commit_msg = arguments.get("message") - self.rev_range = arguments.get("rev_range") + rev_range = arguments.get("rev_range") + # Expand env vars so the packaged ``commitizen-branch`` pre-push hook + # (which passes the literal ``$PRE_COMMIT_FROM_REF..$PRE_COMMIT_TO_REF``) + # keeps working after the ``shell=False`` switch in #1941. See #2003. + self.rev_range = os.path.expandvars(rev_range) if rev_range else rev_range self.allow_abort = bool( arguments.get("allow_abort", config.settings["allow_abort"]) ) diff --git a/docs/images/cli_interactive/bump.gif b/docs/images/cli_interactive/bump.gif index 0d7cd5485..fdf09310d 100644 Binary files a/docs/images/cli_interactive/bump.gif and b/docs/images/cli_interactive/bump.gif differ diff --git a/docs/images/cli_interactive/commit.gif b/docs/images/cli_interactive/commit.gif index 53d031da0..35392de01 100644 Binary files a/docs/images/cli_interactive/commit.gif and b/docs/images/cli_interactive/commit.gif differ diff --git a/docs/images/cli_interactive/init.gif b/docs/images/cli_interactive/init.gif index 1f7f3ffe0..96504321b 100644 Binary files a/docs/images/cli_interactive/init.gif and b/docs/images/cli_interactive/init.gif differ diff --git a/docs/images/cli_interactive/shortcut_custom.gif b/docs/images/cli_interactive/shortcut_custom.gif index 0876dd91e..69562f396 100644 Binary files a/docs/images/cli_interactive/shortcut_custom.gif and b/docs/images/cli_interactive/shortcut_custom.gif differ diff --git a/docs/images/cli_interactive/shortcut_default.gif b/docs/images/cli_interactive/shortcut_default.gif index 20d1418e8..359943130 100644 Binary files a/docs/images/cli_interactive/shortcut_default.gif and b/docs/images/cli_interactive/shortcut_default.gif differ diff --git a/docs/tutorials/github_actions.md b/docs/tutorials/github_actions.md index 24a55fc79..7717bdbf5 100644 --- a/docs/tutorials/github_actions.md +++ b/docs/tutorials/github_actions.md @@ -123,6 +123,127 @@ jobs: You can find the complete workflow in our repository at [bumpversion.yml](https://github.com/commitizen-tools/commitizen/blob/master/.github/workflows/bumpversion.yml). +### Previewing the version bump on pull requests + +To help reviewers spot unexpected version bumps before merging, you can run +`cz bump --dry-run` on every pull request and post (or update) a sticky +comment summarizing the would-be version bump. + +Create `.github/workflows/pr-bump-preview.yml`: + +```yaml title=".github/workflows/pr-bump-preview.yml" +name: PR bump preview + +on: + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review] + +permissions: + contents: read + pull-requests: write + +jobs: + bump-preview: + # Skip drafts and fork PRs (see "How it works" below). + if: > + ${{ + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == + github.event.pull_request.base.repo.full_name + }} + runs-on: ubuntu-latest + steps: + - name: Check out PR head + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + - uses: commitizen-tools/setup-cz@main + with: + set-git-config: false + - name: Run cz bump --dry-run + id: dry-run + run: | + set +e + output="$(cz bump --dry-run --yes 2>&1)" + status=$? + set -e + { + echo "status=${status}" + echo "output<<__CZ_BUMP_PREVIEW__" + printf '%s\n' "${output}" + echo "__CZ_BUMP_PREVIEW__" + } >> "$GITHUB_OUTPUT" + - name: Build comment body + env: + STATUS: ${{ steps.dry-run.outputs.status }} + OUTPUT: ${{ steps.dry-run.outputs.output }} + run: | + { + echo "" + echo "## 🔍 Commitizen bump preview" + echo "" + case "${STATUS}" in + 0) + echo "Merging this PR will produce the following bump:" + echo "" + echo '```' + printf '%s\n' "${OUTPUT}" + echo '```' + ;; + 21) + echo "No commits in this PR are eligible for a version bump." + ;; + *) + echo "⚠️ \`cz bump --dry-run\` exited with status \`${STATUS}\`:" + echo "" + echo '```' + printf '%s\n' "${OUTPUT}" + echo '```' + ;; + esac + } > comment.md + - uses: peter-evans/create-or-update-comment@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + issue-number: ${{ github.event.pull_request.number }} + body-path: comment.md + body-includes: "" + edit-mode: replace +``` + +#### How it works + +- **Trigger**: `pull_request_target` runs in the context of the base + repository, which gives the workflow `pull-requests: write` permission + even for PRs from forks. We deliberately gate the job to **same-repo PRs + only** (`head.repo == base.repo`); fork PRs are skipped. This is because + `cz bump` renders [Jinja templates from the working directory][jinja] + whenever [`update_changelog_on_bump`](../config/configuration_file.md) is + enabled, and the renderer is not sandboxed — running it against + fork-controlled files under a write token would risk arbitrary code + execution and token exfiltration. Same-repo PRs are written by + collaborators who already have push access, so the same risk doesn't + apply. +- **Setup**: [`commitizen-tools/setup-cz`](https://github.com/commitizen-tools/setup-cz) + installs the Commitizen CLI; no language-specific build tooling is required. +- **Defense in depth**: `persist-credentials: false` on `actions/checkout` + keeps the workflow token out of the local git config. +- **Dry-run**: `cz bump --dry-run --yes` computes the next version (and, if + `update_changelog_on_bump` is set in your config, also the changelog + entries that would be produced). Exit code `21` (`NoneIncrementExit`) + is treated as "no eligible bump" rather than a failure. +- **Sticky comment**: The hidden HTML marker `` + lets [`peter-evans/create-or-update-comment`](https://github.com/peter-evans/create-or-update-comment) + find and replace the previous preview on every push, instead of leaving a + growing trail of comments. + +[jinja]: https://github.com/commitizen-tools/commitizen/blob/master/commitizen/changelog.py + +You can find the complete workflow in our repository at [pr-bump-preview.yml](https://github.com/commitizen-tools/commitizen/blob/master/.github/workflows/pr-bump-preview.yml). + ### Publishing a Python package After a new version tag is created by the bump workflow, you can automatically publish your package to PyPI. diff --git a/pyproject.toml b/pyproject.toml index 6cc3e985c..3fcb69437 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "commitizen" -version = "4.16.2" +version = "4.16.3" description = "Python commitizen client tool" authors = [{ name = "Santiago Fraire", email = "santiwilly@gmail.com" }] maintainers = [ diff --git a/tests/commands/test_check_command.py b/tests/commands/test_check_command.py index d587fc070..227fa5fa5 100644 --- a/tests/commands/test_check_command.py +++ b/tests/commands/test_check_command.py @@ -5,7 +5,7 @@ import pytest -from commitizen import commands, git +from commitizen import cmd, commands, git from commitizen.cz import registry from commitizen.cz.base import BaseCommitizen from commitizen.exceptions import ( @@ -172,6 +172,53 @@ def test_check_a_range_of_git_commits_and_failed(config, mocker: MockFixture): commands.Check(config=config, arguments={"rev_range": "HEAD~10..master"})() +def test_check_rev_range_expands_env_vars( + config, success_mock: MockType, mocker: MockFixture, monkeypatch: pytest.MonkeyPatch +): + """The ``commitizen-branch`` pre-push hook passes the literal string + ``$PRE_COMMIT_FROM_REF..$PRE_COMMIT_TO_REF`` and relies on env-var + expansion. Regression test for + https://github.com/commitizen-tools/commitizen/issues/2003. + """ + monkeypatch.setenv("PRE_COMMIT_FROM_REF", "abc123") + monkeypatch.setenv("PRE_COMMIT_TO_REF", "def456") + get_commits = mocker.patch( + "commitizen.git.get_commits", + return_value=_build_fake_git_commits(COMMIT_LOG), + ) + + commands.Check( + config=config, + arguments={"rev_range": "$PRE_COMMIT_FROM_REF..$PRE_COMMIT_TO_REF"}, + )() + + success_mock.assert_called_once() + get_commits.assert_called_once_with(None, "abc123..def456") + + +def test_check_rev_range_leaves_unset_env_vars_literal( + config, mocker: MockFixture, monkeypatch: pytest.MonkeyPatch +): + """Unset env-var references should pass through unchanged so git can + surface a clear ``ambiguous argument`` error instead of being silently + rewritten to an empty range.""" + monkeypatch.delenv("PRE_COMMIT_FROM_REF", raising=False) + monkeypatch.delenv("PRE_COMMIT_TO_REF", raising=False) + get_commits = mocker.patch( + "commitizen.git.get_commits", + return_value=_build_fake_git_commits(COMMIT_LOG), + ) + + commands.Check( + config=config, + arguments={"rev_range": "$PRE_COMMIT_FROM_REF..$PRE_COMMIT_TO_REF"}, + )() + + get_commits.assert_called_once_with( + None, "$PRE_COMMIT_FROM_REF..$PRE_COMMIT_TO_REF" + ) + + def test_check_command_with_invalid_argument(config): with pytest.raises( InvalidCommandArgumentError, @@ -193,6 +240,49 @@ def test_check_command_with_empty_range(config: BaseConfig, util: UtilFixture): commands.Check(config=config, arguments={"rev_range": "master..master"})() +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_check_rev_range_pre_commit_branch_hook_regression( + config: BaseConfig, + util: UtilFixture, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +): + """End-to-end regression test for the packaged ``commitizen-branch`` + pre-push hook. + + The hook in ``.pre-commit-hooks.yaml`` runs:: + + cz check --rev-range "$PRE_COMMIT_FROM_REF..$PRE_COMMIT_TO_REF" + + ``pre-commit`` exports those refs as environment variables but does + *not* expand them in argv, so the literal string reaches ``cz check``. + Before this fix, ``Check`` forwarded that literal to ``git log`` via + ``shell=False`` (PR #1941, CWE-78 hardening) and git aborted with + ``fatal: ambiguous argument '$PRE_COMMIT_FROM_REF..$PRE_COMMIT_TO_REF'``. + + This test exercises the real subprocess path -- no mocks on + ``git.get_commits`` -- to guard against any future regression that + bypasses env-var expansion in the rev-range argument. + + See https://github.com/commitizen-tools/commitizen/issues/2003. + """ + util.create_file_and_commit("feat: initial") + util.create_file_and_commit("fix: second commit") + + from_ref = cmd.run(["git", "rev-parse", "HEAD~1"]).out.strip() + to_ref = cmd.run(["git", "rev-parse", "HEAD"]).out.strip() + monkeypatch.setenv("PRE_COMMIT_FROM_REF", from_ref) + monkeypatch.setenv("PRE_COMMIT_TO_REF", to_ref) + + commands.Check( + config=config, + arguments={"rev_range": "$PRE_COMMIT_FROM_REF..$PRE_COMMIT_TO_REF"}, + )() + + captured = capsys.readouterr() + assert "Commit validation: successful!" in captured.out + + def test_check_a_range_of_failed_git_commits(config, mocker: MockFixture): ill_formatted_commits_msgs = [ "First commit does not follow rule", diff --git a/tests/test_cli.py b/tests/test_cli.py index b04dd9b98..f6e1c17e7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,5 @@ import os +import re import subprocess import sys import types @@ -17,6 +18,29 @@ ) from tests.utils import UtilFixture +ARGPARSE_CHOICES_PATTERN = re.compile(r"\(choose from (?P[^)]*)\)") +ARGPARSE_QUOTED_CHOICE_PATTERN = re.compile(r"'([^']*)'") + + +def normalize_argparse_choice_quotes(text: str) -> str: + def normalize_match(match: re.Match[str]) -> str: + choices = ARGPARSE_QUOTED_CHOICE_PATTERN.sub(r"\1", match.group("choices")) + return f"(choose from {choices})" + + return ARGPARSE_CHOICES_PATTERN.sub(normalize_match, text) + + +def test_normalize_argparse_choice_quotes(): + text = ( + "cz: error: argument {init,commit}: invalid choice: 'invalidCommand' " + "(choose from 'init', 'commit')" + ) + + assert normalize_argparse_choice_quotes(text) == ( + "cz: error: argument {init,commit}: invalid choice: 'invalidCommand' " + "(choose from init, commit)" + ) + @pytest.mark.usefixtures("python_version", "consistent_terminal_output") def test_no_argv(util: UtilFixture, capsys, file_regression): @@ -29,29 +53,7 @@ def test_no_argv(util: UtilFixture, capsys, file_regression): @pytest.mark.parametrize( "arg", - [ - "--invalid-arg", - pytest.param( - "invalidCommand", - marks=pytest.mark.skipif( - (3, 14, 5) <= sys.version_info < (3, 15), - reason=( - "Python 3.14.5 restored argparse choice quoting (CPython " - "gh-130750); the checked-in fixture matches the 3.14.0-4 " - "unquoted format. See #1990." - ), - ), - ), - ], -) -@pytest.mark.skipif( - sys.version_info[:2] == (3, 12) and sys.version_info < (3, 12, 7), - reason=( - "argparse stopped quoting choices in 3.13 (CPython gh-129019), " - "backported to 3.12.7. The reference snapshot reflects the " - "no-quote format, so older 3.12.x patches (3.12.0-3.12.6) print " - "quoted choices and fail. See commitizen-tools/commitizen#1864." - ), + ["--invalid-arg", "invalidCommand"], ) @pytest.mark.usefixtures("python_version", "consistent_terminal_output") def test_invalid_command(util: UtilFixture, capsys, file_regression, arg): @@ -59,6 +61,8 @@ def test_invalid_command(util: UtilFixture, capsys, file_regression, arg): util.run_cli(arg) out, err = capsys.readouterr() assert out == "" + if arg == "invalidCommand": + err = normalize_argparse_choice_quotes(err) file_regression.check(err, extension=".txt") diff --git a/tests/test_cli/test_invalid_command_py_3_10_invalidCommand_.txt b/tests/test_cli/test_invalid_command_py_3_10_invalidCommand_.txt index e2d4416b8..c92220c4d 100644 --- a/tests/test_cli/test_invalid_command_py_3_10_invalidCommand_.txt +++ b/tests/test_cli/test_invalid_command_py_3_10_invalidCommand_.txt @@ -1,4 +1,4 @@ usage: cz [-h] [--config CONFIG] [--debug] [-n NAME] [-nr NO_RAISE] {init,commit,c,ls,example,info,schema,bump,changelog,ch,check,version} ... -cz: error: argument {init,commit,c,ls,example,info,schema,bump,changelog,ch,check,version}: invalid choice: 'invalidCommand' (choose from 'init', 'commit', 'c', 'ls', 'example', 'info', 'schema', 'bump', 'changelog', 'ch', 'check', 'version') +cz: error: argument {init,commit,c,ls,example,info,schema,bump,changelog,ch,check,version}: invalid choice: 'invalidCommand' (choose from init, commit, c, ls, example, info, schema, bump, changelog, ch, check, version) diff --git a/tests/test_cli/test_invalid_command_py_3_11_invalidCommand_.txt b/tests/test_cli/test_invalid_command_py_3_11_invalidCommand_.txt index e2d4416b8..c92220c4d 100644 --- a/tests/test_cli/test_invalid_command_py_3_11_invalidCommand_.txt +++ b/tests/test_cli/test_invalid_command_py_3_11_invalidCommand_.txt @@ -1,4 +1,4 @@ usage: cz [-h] [--config CONFIG] [--debug] [-n NAME] [-nr NO_RAISE] {init,commit,c,ls,example,info,schema,bump,changelog,ch,check,version} ... -cz: error: argument {init,commit,c,ls,example,info,schema,bump,changelog,ch,check,version}: invalid choice: 'invalidCommand' (choose from 'init', 'commit', 'c', 'ls', 'example', 'info', 'schema', 'bump', 'changelog', 'ch', 'check', 'version') +cz: error: argument {init,commit,c,ls,example,info,schema,bump,changelog,ch,check,version}: invalid choice: 'invalidCommand' (choose from init, commit, c, ls, example, info, schema, bump, changelog, ch, check, version) diff --git a/uv.lock b/uv.lock index acfbcc1ff..e07a6fb8b 100644 --- a/uv.lock +++ b/uv.lock @@ -216,7 +216,7 @@ wheels = [ [[package]] name = "commitizen" -version = "4.16.2" +version = "4.16.3" source = { editable = "." } dependencies = [ { name = "argcomplete" }, @@ -629,11 +629,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] @@ -1360,15 +1360,15 @@ wheels = [ [[package]] name = "pymdown-extensions" -version = "10.21.2" +version = "10.21.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, ] [[package]] @@ -1912,28 +1912,28 @@ wheels = [ [[package]] name = "uv" -version = "0.11.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dd/f3/8aceeab67ea69805293ab290e7ca8cc1b61a064d28b8a35c76d8eba063dd/uv-0.11.6.tar.gz", hash = "sha256:e3b21b7e80024c95ff339fcd147ac6fc3dd98d3613c9d45d3a1f4fd1057f127b", size = 4073298, upload-time = "2026-04-09T12:09:01.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/fe/4b61a3d5ad9d02e8a4405026ccd43593d7044598e0fa47d892d4dafe44c9/uv-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:ada04dcf89ddea5b69d27ac9cdc5ef575a82f90a209a1392e930de504b2321d6", size = 23780079, upload-time = "2026-04-09T12:08:56.609Z" }, - { url = "https://files.pythonhosted.org/packages/52/db/d27519a9e1a5ffee9d71af1a811ad0e19ce7ab9ae815453bef39dd479389/uv-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5be013888420f96879c6e0d3081e7bcf51b539b034a01777041934457dfbedf3", size = 23214721, upload-time = "2026-04-09T12:09:32.228Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8f/4399fa8b882bd7e0efffc829f73ab24d117d490a93e6bc7104a50282b854/uv-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ffa5dc1cbb52bdce3b8447e83d1601a57ad4da6b523d77d4b47366db8b1ceb18", size = 21750109, upload-time = "2026-04-09T12:09:24.357Z" }, - { url = "https://files.pythonhosted.org/packages/32/07/5a12944c31c3dda253632da7a363edddb869ed47839d4d92a2dc5f546c93/uv-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:bfb107b4dade1d2c9e572992b06992d51dd5f2136eb8ceee9e62dd124289e825", size = 23551146, upload-time = "2026-04-09T12:09:10.439Z" }, - { url = "https://files.pythonhosted.org/packages/79/5b/2ec8b0af80acd1016ed596baf205ddc77b19ece288473b01926c4a9cf6db/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:9e2fe7ce12161d8016b7deb1eaad7905a76ff7afec13383333ca75e0c4b5425d", size = 23331192, upload-time = "2026-04-09T12:09:34.792Z" }, - { url = "https://files.pythonhosted.org/packages/62/7d/eea35935f2112b21c296a3e42645f3e4b1aa8bcd34dcf13345fbd55134b7/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ed9c6f70c25e8dfeedddf4eddaf14d353f5e6b0eb43da9a14d3a1033d51d915", size = 23337686, upload-time = "2026-04-09T12:09:18.522Z" }, - { url = "https://files.pythonhosted.org/packages/21/47/2584f5ab618f6ebe9bdefb2f765f2ca8540e9d739667606a916b35449eec/uv-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68a013e609cebf82077cbeeb0809ed5e205257814273bfd31e02fc0353bbfc2", size = 25008139, upload-time = "2026-04-09T12:09:03.983Z" }, - { url = "https://files.pythonhosted.org/packages/95/81/497ae5c1d36355b56b97dc59f550c7e89d0291c163a3f203c6f341dff195/uv-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93f736dddca03dae732c6fdea177328d3bc4bf137c75248f3d433c57416a4311", size = 25712458, upload-time = "2026-04-09T12:09:07.598Z" }, - { url = "https://files.pythonhosted.org/packages/3c/1c/74083238e4fab2672b63575b9008f1ea418b02a714bcfcf017f4f6a309b6/uv-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e96a66abe53fced0e3389008b8d2eff8278cfa8bb545d75631ae8ceb9c929aba", size = 24915507, upload-time = "2026-04-09T12:08:50.892Z" }, - { url = "https://files.pythonhosted.org/packages/5a/ee/e14fe10ba455a823ed18233f12de6699a601890905420b5c504abf115116/uv-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b096311b2743b228df911a19532b3f18fa420bf9530547aecd6a8e04bbfaccd", size = 24971011, upload-time = "2026-04-09T12:08:54.016Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/7b9c83eaadf98e343317ff6384a7227a4855afd02cdaf9696bcc71ee6155/uv-0.11.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:904d537b4a6e798015b4a64ff5622023bd4601b43b6cd1e5f423d63471f5e948", size = 23640234, upload-time = "2026-04-09T12:09:15.735Z" }, - { url = "https://files.pythonhosted.org/packages/d6/51/75ccdd23e76ff1703b70eb82881cd5b4d2a954c9679f8ef7e0136ef2cfab/uv-0.11.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:4ed8150c26b5e319381d75ae2ce6aba1e9c65888f4850f4e3b3fa839953c90a5", size = 24452664, upload-time = "2026-04-09T12:09:26.875Z" }, - { url = "https://files.pythonhosted.org/packages/4d/86/ace80fe47d8d48b5e3b5aee0b6eb1a49deaacc2313782870250b3faa36f5/uv-0.11.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1c9218c8d4ac35ca6e617fb0951cc0ab2d907c91a6aea2617de0a5494cf162c0", size = 24494599, upload-time = "2026-04-09T12:09:37.368Z" }, - { url = "https://files.pythonhosted.org/packages/05/2d/4b642669b56648194f026de79bc992cbfc3ac2318b0a8d435f3c284934e8/uv-0.11.6-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9e211c83cc890c569b86a4183fcf5f8b6f0c7adc33a839b699a98d30f1310d3a", size = 24159150, upload-time = "2026-04-09T12:09:13.17Z" }, - { url = "https://files.pythonhosted.org/packages/ae/24/7eecd76fe983a74fed1fc700a14882e70c4e857f1d562a9f2303d4286c12/uv-0.11.6-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d2a1d2089afdf117ad19a4c1dd36b8189c00ae1ad4135d3bfbfced82342595cf", size = 25164324, upload-time = "2026-04-09T12:08:59.56Z" }, - { url = "https://files.pythonhosted.org/packages/27/e0/bbd4ba7c2e5067bbba617d87d306ec146889edaeeaa2081d3e122178ca08/uv-0.11.6-py3-none-win32.whl", hash = "sha256:6e8344f38fa29f85dcfd3e62dc35a700d2448f8e90381077ef393438dcd5012e", size = 22865693, upload-time = "2026-04-09T12:09:21.415Z" }, - { url = "https://files.pythonhosted.org/packages/a5/33/1983ce113c538a856f2d620d16e39691962ecceef091a84086c5785e32e5/uv-0.11.6-py3-none-win_amd64.whl", hash = "sha256:a28bea69c1186303d1200f155c7a28c449f8a4431e458fcf89360cc7ef546e40", size = 25371258, upload-time = "2026-04-09T12:09:40.52Z" }, - { url = "https://files.pythonhosted.org/packages/35/01/be0873f44b9c9bc250fcbf263367fcfc1f59feab996355bcb6b52fff080d/uv-0.11.6-py3-none-win_arm64.whl", hash = "sha256:a78f6d64b9950e24061bc7ec7f15ff8089ad7f5a976e7b65fcadce58fe02f613", size = 23869585, upload-time = "2026-04-09T12:09:29.425Z" }, +version = "0.11.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/609d5d01ba21dc8f0974610ca7802fbb2c946a0c38665cfe5c5aeddbefb5/uv-0.11.15.tar.gz", hash = "sha256:755f959ec6a2fd8ccb6ee76ad90ab759d2eb1f4797444078645dd1ee4bca92d6", size = 4159545, upload-time = "2026-05-18T19:57:48.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/7c/dcc230c5911884d8848145dabcac8fb95a5ed6f9fe1c57fae8242618f28a/uv-0.11.15-py3-none-linux_armv6l.whl", hash = "sha256:83b04ab49514a0a761ffedb36a748ee81f87746671e72088e5f32c9585e5f1a9", size = 23110183, upload-time = "2026-05-18T19:57:23.051Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/efd4e044b60eb9c3c12ee386be098d56c335538ccec7caa49349cfba9344/uv-0.11.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6cae61f737be075b90be9e3f07d961072aed7019f4c9b8ed5c5d41c4d6cade3", size = 22637941, upload-time = "2026-05-18T19:57:26.752Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b8/48627f895a1569e576822e0a8416aa4797eb4a4551de21a4ad97b9b5819d/uv-0.11.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9accae33619a9166e5c48531deb455d672cfb89f9357a00975e669c76b0bd49f", size = 21258803, upload-time = "2026-05-18T19:57:05.473Z" }, + { url = "https://files.pythonhosted.org/packages/af/50/4bc8a148274feabee2d9c9f1fa15009e10c0228dfe57981ee3ea2ef1d481/uv-0.11.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:c0cf52cd6d50bb9e05e2d968f45f80761107e4cbc8d4a26d9758f9d8274aaec1", size = 23066178, upload-time = "2026-05-18T19:57:33.058Z" }, + { url = "https://files.pythonhosted.org/packages/a9/56/139fc3bec9a8b0a25bfe2196123adb9f16124da437bf4fbcf0d21cfcafb2/uv-0.11.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:49dc6ed70bff00937384f96cdc4b1a4742d18e5504ec2c4a1214dba2dee5687a", size = 22705332, upload-time = "2026-05-18T19:57:36.714Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b0/b18b3dd204f8c213236a1ebd148e009861637129a8cce34df0e9aa22ed40/uv-0.11.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:adb9a89352539fdd8f7cd5f9966cf9f94fc5b98e0ccdf5003a04123dc6423bec", size = 22707534, upload-time = "2026-05-18T19:58:04.117Z" }, + { url = "https://files.pythonhosted.org/packages/76/36/3ca09f95572df99d361b49c96b1297149e96e120d8d1ecf074095a4b6da4/uv-0.11.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40ff67e3f8e8a7533781a2e892a534975a93acb83ea35460e64e7b2bf2111774", size = 24096607, upload-time = "2026-05-18T19:58:11.625Z" }, + { url = "https://files.pythonhosted.org/packages/64/be/3bdee21a296bbf5336a526e3613d0e7d4538dacc39c62d7fcba55d15f6b0/uv-0.11.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6463a299ed7e6b5a800ed6f108af8e1588352629424133ddef7572b0e1e1118", size = 25082562, upload-time = "2026-05-18T19:57:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/cd/73/f371f3689ffe741066468d001d85f739fc4b5574de83b639ef19b5e8a7f4/uv-0.11.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68c1e62d4b78578b90b833553286b65d6a7e327537716441068583ba652ec4f5", size = 24253391, upload-time = "2026-05-18T19:57:18.47Z" }, + { url = "https://files.pythonhosted.org/packages/d3/16/fe392d618af6b00c064b3e718d585dcf791546a77c5123a5bec07ce53a0a/uv-0.11.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98edf1bdaf82447014852051d93e3ee95012509c567bf057fd117e6bdbd9a807", size = 24415871, upload-time = "2026-05-18T19:58:19.651Z" }, + { url = "https://files.pythonhosted.org/packages/6e/24/2e92a052fb6334fcd746d1c7cb57847c204b118c84f5da53c0f9e129f7b7/uv-0.11.15-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:be8f76d25bcf4c92bb384240ac1bf9aa7f51063d0bdeca4c9cf0ec3ed8b145e0", size = 23159007, upload-time = "2026-05-18T19:57:10.653Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2e/6923d0658d164bb2c435ed1868aa2d49b3074594679917a001ff92dc95bb/uv-0.11.15-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:f9f4fbbf4fe485522054f3c7496c6e8e932d6436e4200ff3daf718db0b7c7bd5", size = 23769385, upload-time = "2026-05-18T19:58:15.856Z" }, + { url = "https://files.pythonhosted.org/packages/a4/99/7e34cd949e57360814e8064cc9fb7104df445d0f6a663504e5f7473480aa/uv-0.11.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0ed920e896b2fd13a35031707e307e42fbb2681458b967440a17272d86d49137", size = 23860973, upload-time = "2026-05-18T19:57:55.575Z" }, + { url = "https://files.pythonhosted.org/packages/28/98/8fe1f5f9d816e94569a0298dd8e0936801097625fa1952162951f0d628b6/uv-0.11.15-py3-none-musllinux_1_1_i686.whl", hash = "sha256:41d907611f3e6a13262807fd7f0a17849f76285ca80f536f6b3943732bdc6656", size = 23431392, upload-time = "2026-05-18T19:57:59.814Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6b/76a1ce2fa860026913a5941700cdc7d715fce9c3277a3fa3489cf2523ca0/uv-0.11.15-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e3b68f8bf1a4568710f77e5bda9182ce7682811d89a8e7468c22460e032b234d", size = 24519478, upload-time = "2026-05-18T19:57:51.165Z" }, + { url = "https://files.pythonhosted.org/packages/43/60/1d58e8a05718cb50494763115710b73846cacb651fd735d285233fd72c59/uv-0.11.15-py3-none-win32.whl", hash = "sha256:8e2da3076761086a5b76869c3f38ef0509c836046ef41ddd19485dfd7271dca9", size = 22020178, upload-time = "2026-05-18T19:58:07.64Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/40fcefcb348af660488597ed3c01363df7344e60611f8883750dc596f5c6/uv-0.11.15-py3-none-win_amd64.whl", hash = "sha256:cc3915ab291a1ecaf31de05f5d8bd70d09c66fe9911a53f70d9efa62ff0dbd8a", size = 24668779, upload-time = "2026-05-18T19:57:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7d/fa3a9960c95af9bbe2a629048760d0b9b4fead8ccd4f2235af747ec7cdf0/uv-0.11.15-py3-none-win_arm64.whl", hash = "sha256:4f39426a13dee24897aed60c4b98058c66f18bd983885ac5f4a54a04b24fbddf", size = 23198178, upload-time = "2026-05-18T19:57:14.68Z" }, ] [[package]]