diff --git a/.agents/skills/commitizen/SKILL.md b/.agents/skills/commitizen/SKILL.md new file mode 100644 index 0000000000..0aa3871b8a --- /dev/null +++ b/.agents/skills/commitizen/SKILL.md @@ -0,0 +1,74 @@ +--- +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" +--- + +# Commitizen + +Commitizen is a CLI for enforcing Conventional Commits, automating version bumps, and generating changelogs. + +## Use this skill when + +- A task involves commit message authoring or validation. +- A repository needs Commitizen initialization or configuration updates. +- Work depends on version schemes, version providers, version files, tags, or changelog behavior. +- CI/CD automation needs commit validation, automated version bumps, or release notes. + +## Core workflow + +1. Find the active configuration file in this order: `.cz.toml`, `cz.toml`, `.cz.json`, `cz.json`, `.cz.yaml`, `cz.yaml`, then `pyproject.toml` under `[tool.commitizen]`. +2. Read the effective settings before acting, especially `name`, `version`, `version_provider`, `version_scheme`, `version_files`, `tag_format`, `update_changelog_on_bump`, `annotated_tag`, `bump_message`, `pre_bump_hooks`, and `post_bump_hooks`. +3. Match the command to the task: + - `cz commit` for interactive commit authoring + - `cz check` for validating commit messages or git ranges + - `cz init` for bootstrapping configuration + - `cz bump` for calculating or applying release versions + - `cz changelog` for generating or updating `CHANGELOG.md` + - `cz ls` for listing available commit rules + - `cz version` for showing the current version +4. Prefer read-only inspection first. Safe discovery commands include `cz version`, `cz ls`, `cz check`, `cz bump --get-next`, and `cz bump --dry-run`. +5. Treat `cz bump` as stateful: it can update version files, create a bump commit, and create a git tag. Verify the version provider, version scheme, tag format, and changelog settings before running it for real. +6. When automating in CI, check whether the workflow should ignore specific exit codes with `--no-raise` and whether `bump_message` should include skip-CI text. +7. After making changes, validate the resulting configuration, commands, and automation against the repository's actual version scheme and provider. + +## Important domain details + +- Commitizen installs with `pip install commitizen` or `uv add commitizen`. +- 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"` +- Validate branch history: `cz check --rev-range master..HEAD` +- Preview the next version: `cz bump --get-next` +- Preview bump details: `cz bump --dry-run` +- Preview changelog output: `cz changelog --dry-run` +- Initialize configuration: `cz init` diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 5d29c85b70..0000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @woile @Lee-W @noirbizarre diff --git a/.github/workflows/bumpversion.yml b/.github/workflows/bumpversion.yml index 00e8604e2f..f4068daf5b 100644 --- a/.github/workflows/bumpversion.yml +++ b/.github/workflows/bumpversion.yml @@ -38,18 +38,29 @@ jobs: git-user-email: "${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com" - id: bump-version run: | - cz bump --yes + old_sha="$(git rev-parse HEAD)" + cz --no-raise 21 bump --yes + + if [ "$(git rev-parse HEAD)" = "$old_sha" ]; then + echo "No bump-eligible commits found, skipping release." + echo "bumped=false" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "bumped=true" >> $GITHUB_OUTPUT git push --follow-tags new_version="$(cz version -p)" echo "new_version=$new_version" >> $GITHUB_OUTPUT new_version_tag="$(cz version -p --tag)" echo "new_version_tag=$new_version_tag" >> $GITHUB_OUTPUT - name: Build changelog for Release + if: steps.bump-version.outputs.bumped == 'true' env: NEW_VERSION: ${{ steps.bump-version.outputs.new_version }} run: | cz changelog --dry-run "${NEW_VERSION}" > .changelog.md - name: Release + if: steps.bump-version.outputs.bumped == 'true' env: GH_TOKEN: ${{ github.token }} NEW_VERSION_TAG: ${{ steps.bump-version.outputs.new_version_tag }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 38bcbdf822..634c1bedf6 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.15.1 # automatically updated by Commitizen + rev: v4.16.2 # automatically updated by Commitizen hooks: - id: commitizen - id: commitizen-branch diff --git a/AGENTS.md b/AGENTS.md index be39dfd536..2e08e188a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,13 +22,17 @@ Follow these instructions in addition to any higher-level system or tool rules. - **Preserve public behavior and CLI UX** — no breaking changes to APIs, CLI flags, or exit codes unless explicitly requested. - **Update or add tests/docs** when you change user-facing behavior. - **Commit messages** must follow [Conventional Commits](https://www.conventionalcommits.org/) (enforced by commitizen itself). +- **Pull requests** must follow the [Pull Request Guidelines](docs/contributing/pull_request.md) and the template in `.github/pull_request_template.md`. ## Setup and Validation +> Full contributor guidelines (prerequisites, workflow, PR process): [`docs/contributing/contributing.md`](docs/contributing/contributing.md). + ### Bootstrap ```bash uv sync --frozen --group base --group test --group linters +uv run poe setup-pre-commit # install git hooks (uses prek, a pre-commit runner) ``` ### Local commands @@ -44,7 +48,7 @@ Always run at least `uv run ruff check --fix . && uv run ruff format .` before p ### CI pipeline - CI runs `poe ci` on a matrix of Python 3.10–3.14 × ubuntu/macos/windows. -- Pre-commit hooks are defined in `.pre-commit-config.yaml` and run via `prek`. +- Pre-commit hooks are defined in `.pre-commit-config.yaml` and run via [`prek`](https://github.com/j178/prek) (a `pre-commit` compatible runner). - The matrix is **fail-fast**: inspect the earliest failing job that completed; others are cancelled. ### Common CI failure patterns diff --git a/commitizen/__version__.py b/commitizen/__version__.py index 5df3885243..e2111981b5 100644 --- a/commitizen/__version__.py +++ b/commitizen/__version__.py @@ -1 +1 @@ -__version__ = "4.15.1" +__version__ = "4.16.2" diff --git a/commitizen/cmd.py b/commitizen/cmd.py index effe1ff37f..f2b98502dd 100644 --- a/commitizen/cmd.py +++ b/commitizen/cmd.py @@ -103,3 +103,43 @@ def run_shell(cmd: str, env: Mapping[str, str] | None = None) -> Command: https://github.com/commitizen-tools/commitizen/issues/1918 """ return _popen(cmd, shell=True, env=env) + + +def run_interactive( + cmd: str | Sequence[str], env: Mapping[str, str] | None = None +) -> int: + """Run a command safely without shell interpretation and without redirecting stdin, stdout, or stderr + + Args: + cmd: The command to run + env: Extra environment variables to define in the subprocess. Defaults to None. + + Returns: + subprocess returncode + """ + if env is not None: + env = {**os.environ, **env} + if isinstance(cmd, str): + warnings.warn( + "Passing a string to cmd.run_interactive() is deprecated and will be removed in v5. " + "Use a list of arguments instead, or use cmd.run_interactive_shell() explicitly.", + DeprecationWarning, + stacklevel=2, + ) + return subprocess.run(cmd, shell=True, env=env).returncode + return subprocess.run(cmd, shell=False, env=env).returncode + + +def run_interactive_shell(cmd: str, env: Mapping[str, str] | None = None) -> int: + """Run a command without redirecting stdin, stdout, or stderr + + Args: + cmd: The command to run + env: Extra environment variables to define in the subprocess. Defaults to None. + + Returns: + subprocess returncode + """ + if env is not None: + env = {**os.environ, **env} + return subprocess.run(cmd, shell=True, env=env).returncode diff --git a/commitizen/cz/customize/customize.py b/commitizen/cz/customize/customize.py index 8fcc63fac3..8f8857d210 100644 --- a/commitizen/cz/customize/customize.py +++ b/commitizen/cz/customize/customize.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections import OrderedDict from pathlib import Path from typing import TYPE_CHECKING, Any @@ -19,11 +20,29 @@ from commitizen import defaults from commitizen.cz.base import BaseCommitizen +from commitizen.defaults import MAJOR, MINOR from commitizen.exceptions import MissingCzCustomizeConfigError __all__ = ["CustomizeCommitsCz"] +def _derive_major_version_zero( + bump_map: Mapping[str, str], +) -> OrderedDict[str, str]: + """Derive a ``bump_map_major_version_zero`` from a user-supplied + ``bump_map`` by demoting any ``MAJOR`` rule to ``MINOR``. + + See #1728: when a ``cz_customize`` user supplies ``bump_map`` but not + ``bump_map_major_version_zero``, the latter previously fell through to + ``defaults.BUMP_MAP_MAJOR_VERSION_ZERO``, silently overriding the + user's intent during ``major_version_zero = true`` bumps. + """ + return OrderedDict( + (pattern, MINOR if increment == MAJOR else increment) + for pattern, increment in bump_map.items() + ) + + class CustomizeCommitsCz(BaseCommitizen): bump_pattern = defaults.BUMP_PATTERN bump_map = defaults.BUMP_MAP @@ -49,6 +68,16 @@ def __init__(self, config: BaseConfig) -> None: if value := self.custom_settings.get(attr_name): setattr(self, attr_name, value) + # When the user supplies a custom ``bump_map`` but no matching + # ``bump_map_major_version_zero``, derive the latter so that bumps + # under ``major_version_zero = true`` use the user's mapping rather + # than the (totally unrelated) ``defaults.BUMP_MAP_MAJOR_VERSION_ZERO`` + # fallback. See #1728. + if self.custom_settings.get("bump_map") and not self.custom_settings.get( + "bump_map_major_version_zero" + ): + self.bump_map_major_version_zero = _derive_major_version_zero(self.bump_map) + def questions(self) -> list[CzQuestion]: return self.custom_settings.get("questions", [{}]) # type: ignore[return-value] diff --git a/commitizen/defaults.py b/commitizen/defaults.py index 4865ccc188..84a3f0159a 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -156,8 +156,17 @@ def get_tag_regexes( "major": r"(?P\d+)", "minor": r"(?P\d+)", "patch": r"(?P\d+)", - "prerelease": r"(?P\w+\d+)?", - "devrelease": r"(?P\.dev\d+)?", + # Allow ``\w+`` (PEP-440 ``rc0``) as well as ``\w+\.\w+(\.\w+)*`` + # (SemVer2 ``rc.0``, ``alpha.beta.1``). The original ``\w+\d+`` only + # matched the PEP-440 form and produced "Invalid version tag" warnings + # for SemVer2-style tags created by commitizen itself (#1614). + "prerelease": r"(?P\w+(?:\.\w+)*)?", + # Match either ``.dev1`` (PEP-440 with leading dot) or ``dev1`` + # (SemVer / SemVer2 / users substituting ``${devrelease}`` directly + # in a ``tag_format`` -- see #1615). A bare ``\d+`` after the prefix + # would let the regex match arbitrary numeric suffixes, so the + # ``dev`` literal is required. + "devrelease": r"(?P\.?dev\d+)?", } return { **{f"${k}": v for k, v in regexes.items()}, diff --git a/commitizen/hooks.py b/commitizen/hooks.py index ccb9666728..cf9fe01d24 100644 --- a/commitizen/hooks.py +++ b/commitizen/hooks.py @@ -17,14 +17,9 @@ def run(hooks: str | list[str], _env_prefix: str = "CZ_", **env: object) -> None for hook in hooks: out.info(f"Running hook '{hook}'") - c = cmd.run_shell(hook, env=_format_env(_env_prefix, env)) + return_code = cmd.run_interactive(hook, env=_format_env(_env_prefix, env)) - if c.out: - out.write(c.out) - if c.err: - out.error(c.err) - - if c.return_code != 0: + if return_code != 0: raise RunHookError(f"Running hook '{hook}' failed") diff --git a/docs/contributing/contributing_tldr.md b/docs/contributing/contributing_tldr.md index af350d2bb7..815eb584b4 100644 --- a/docs/contributing/contributing_tldr.md +++ b/docs/contributing/contributing_tldr.md @@ -2,38 +2,29 @@ Feel free to send a PR to update this file if you find anything useful. 🙇 -## Environment +For prerequisites and initial setup, see [Contributing to Commitizen](contributing.md#prerequisites-setup). -- Python `>=3.10` -- [uv](https://docs.astral.sh/uv/getting-started/installation/) `>=0.9.0` +## Command Cheat Sheet -## Useful commands - -Please check the [pyproject.toml](https://github.com/commitizen-tools/commitizen/blob/master/pyproject.toml) for a comprehensive list of commands. - -### Code Changes +See [pyproject.toml](https://github.com/commitizen-tools/commitizen/blob/master/pyproject.toml) for the full list of poe tasks. ```bash -# Ensure you have the correct dependencies -uv sync --dev --frozen - -# Make ruff happy +# Format code (ruff check --fix + ruff format) uv run poe format -# Check if ruff and mypy are happy +# Lint (ruff check + mypy) uv run poe lint -# Check if mypy is happy in python 3.10 -mypy --python-version 3.10 +# Check mypy against a specific Python version +uv run mypy --python-version 3.10 -# Run tests in parallel. -pytest -n auto # This may take a while. -pytest -n auto -``` +# Run tests in parallel (may take a while) +uv run pytest -n auto +uv run pytest -n auto -### Documentation Changes - -```bash -# Build the documentation locally and check for broken links +# Build and preview docs locally uv run poe doc + +# Run everything (format + lint + check-commit + coverage) +uv run poe all ``` diff --git a/docs/exit_codes.md b/docs/exit_codes.md index 1a214e2832..f99115ed31 100644 --- a/docs/exit_codes.md +++ b/docs/exit_codes.md @@ -57,6 +57,24 @@ The `--no-raise` (or `-nr`) flag allows you to specify exit codes that should no Multiple exit codes can be specified as a comma-separated list. +!!! warning "Flag placement" + `--no-raise` / `-nr` is a **top-level Commitizen flag**, so it must be passed + **before** the subcommand: + + ```sh + cz --no-raise 21 bump --yes # ✅ correct + cz -nr 21 bump --yes # ✅ correct (short form) + ``` + + Placing it after the subcommand fails with exit code 18 + (`InvalidCommandArgumentError`): + + ```sh + cz bump --yes --no-raise 21 # ❌ wrong + # Invalid commitizen arguments were found: `--no-raise`. + # Please use -- separator for extra git args + ``` + ### Common Use Cases #### Ignoring No Increment Errors diff --git a/docs/images/cli_interactive/bump.gif b/docs/images/cli_interactive/bump.gif index b45302fac7..0d7cd54850 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 3cea0d9b9c..53d031da03 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 88ea493c9d..1f7f3ffe01 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 08765305b0..0876dd91ea 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 397f225405..20d1418e8d 100644 Binary files a/docs/images/cli_interactive/shortcut_default.gif and b/docs/images/cli_interactive/shortcut_default.gif differ diff --git a/mkdocs.yml b/mkdocs.yml index 19218c80cc..5f3190f7d7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -113,3 +113,57 @@ markdown_extensions: plugins: - search - git-revision-date-localized + - llmstxt: + full_output: llms-full.txt + markdown_description: > + Commitizen is a release management tool for teams that enforce + Conventional Commits, automate semantic versioning bumps, and + generate changelogs. Start with the introduction for installation + and core workflows, then use the command and configuration sections + for day-to-day usage. Tutorials cover CI integration, commit hooks, + monorepos, and release automation. + sections: + Overview: + - README.md + - faq.md: Answers to common setup and usage questions. + - exit_codes.md: Reference for Commitizen CLI exit codes and failure modes. + Commands: + - commands/init.md: Initialize Commitizen configuration for a project. + - commands/commit.md: Create standardized commits with the interactive prompt. + - commands/bump.md: Calculate next version, update version files, and manage release tagging. + - commands/check.md: Validate commit messages against the configured convention. + - commands/changelog.md: Generate changelog entries from commits and tags. + - commands/example.md: Show example commit messages for the configured rules. + - commands/info.md: Show information about the active configuration. + - commands/ls.md: List supported commit message choices for the active rules. + - commands/schema.md: Show the commit message schema for the active convention. + - commands/version.md: Show the installed or project version. + Configuration: + - config/configuration_file.md: Where configuration can live and how it is loaded. + - config/option.md: Global options such as commit rules, version, and style. + - config/bump.md: Version bumping, tag creation, and version-file update settings. + - config/commit.md: Settings for interactive commit creation and prompts. + - config/check.md: Settings for commit message validation. + - config/changelog.md: Settings for changelog generation and formatting. + - config/version_provider.md: How Commitizen reads and writes versions for different project types. + Advanced Customization: + - customization/config_file.md: Define custom commit and bump rules in project configuration. + - customization/python_class.md: Implement a Python class for fully custom behavior. + - customization/changelog_template.md: Customize changelog output templates. + Tutorials: + - tutorials/writing_commits.md: Writing effective Conventional Commits. + - tutorials/tag_format.md: Configure and work with custom Git tag formats. + - tutorials/auto_check.md: Enforce commit message checks automatically. + - tutorials/auto_prepare_commit_message.md: Pre-fill commit messages before editing. + - tutorials/gitlab_ci.md: Automate release workflows in GitLab CI. + - tutorials/github_actions.md: Automate release workflows in GitHub Actions. + - tutorials/jenkins_pipeline.md: Integrate Commitizen into Jenkins pipelines. + - tutorials/dev_releases.md: Manage development and prerelease versioning. + - tutorials/monorepo_guidance.md: Configure Commitizen for monorepos. + Third-Party Plugins: + - third-party-plugins/about.md: Overview of the plugin ecosystem. + - third-party-plugins/*.md + Contributing: + - contributing/contributing_tldr.md: Fast path for setting up a local dev workflow. + - contributing/contributing.md: Full contributor guide. + - contributing/pull_request.md: Expectations for preparing and submitting PRs. diff --git a/pyproject.toml b/pyproject.toml index f58b2ea0ab..6cc3e985ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "commitizen" -version = "4.15.1" +version = "4.16.2" description = "Python commitizen client tool" authors = [{ name = "Santiago Fraire", email = "santiwilly@gmail.com" }] maintainers = [ @@ -97,7 +97,7 @@ dev = [ "tox-uv", ] -base = ["poethepoet>=0.34.0"] +base = ["mkdocs-llmstxt>=0.5.0", "poethepoet>=0.34.0"] test = [ "pytest>=9", diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index 1e5a162ba0..b26f095c9f 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -314,9 +314,8 @@ def test_bump_tag_exists_raises_exception(util: UtilFixture): util.create_file_and_commit("feat: new file") git.tag("0.2.0") - with pytest.raises(BumpTagFailedError) as excinfo: + with pytest.raises(BumpTagFailedError, match=re.escape("0.2.0")): util.run_cli("bump", "--yes") - assert "0.2.0" in str(excinfo.value) # This should be a fatal error @pytest.mark.usefixtures("tmp_commitizen_project") @@ -338,18 +337,17 @@ def test_bump_when_bumping_is_not_support(util: UtilFixture): "feat: new user interface\n\nBREAKING CHANGE: age is no longer supported" ) - with pytest.raises(NoPatternMapError) as excinfo: + with pytest.raises(NoPatternMapError, match="'cz_jira' rule does not support bump"): util.run_cli("-n", "cz_jira", "bump", "--yes") - assert "'cz_jira' rule does not support bump" in str(excinfo.value) @pytest.mark.usefixtures("tmp_git_project") def test_bump_when_version_is_not_specify(util: UtilFixture): - with pytest.raises(NoVersionSpecifiedError) as excinfo: + with pytest.raises( + NoVersionSpecifiedError, match=re.escape(NoVersionSpecifiedError.message) + ): util.run_cli("bump") - assert NoVersionSpecifiedError.message in str(excinfo.value) - @pytest.mark.usefixtures("tmp_commitizen_project") def test_bump_when_no_new_commit(util: UtilFixture): @@ -360,11 +358,11 @@ def test_bump_when_no_new_commit(util: UtilFixture): util.run_cli("bump", "--yes") # bump without a new commit. - with pytest.raises(NoCommitsFoundError) as excinfo: + with pytest.raises( + NoCommitsFoundError, match=r"\[NO_COMMITS_FOUND\]\nNo new commits found\." + ): util.run_cli("bump", "--yes") - assert "[NO_COMMITS_FOUND]\nNo new commits found." in str(excinfo.value) - def test_bump_when_version_inconsistent_in_version_files( tmp_commitizen_project, util: UtilFixture @@ -378,11 +376,12 @@ def test_bump_when_version_inconsistent_in_version_files( util.create_file_and_commit("feat: new file") - with pytest.raises(CurrentVersionNotFoundError) as excinfo: + with pytest.raises( + CurrentVersionNotFoundError, + match=re.escape("Current version 0.1.0 is not found in"), + ): util.run_cli("bump", "--yes", "--check-consistency") - assert "Current version 0.1.0 is not found in" in str(excinfo.value) - def test_bump_major_version_zero_when_major_is_not_zero( tmp_commitizen_project, util: UtilFixture @@ -403,13 +402,14 @@ def test_bump_major_version_zero_when_major_is_not_zero( util.create_tag("v1.0.0") util.create_file_and_commit("feat(user)!: new file") - with pytest.raises(NotAllowed) as excinfo: + with pytest.raises( + NotAllowed, + match=re.escape( + "--major-version-zero is meaningless for current version 1.0.0" + ), + ): util.run_cli("bump", "--yes", "--major-version-zero") - assert "--major-version-zero is meaningless for current version 1.0.0" in str( - excinfo.value - ) - def test_bump_files_only(tmp_commitizen_project, util: UtilFixture): tmp_version_file = tmp_commitizen_project / "__version__.py" @@ -539,13 +539,14 @@ def test_prevent_prerelease_when_no_increment_detected( util.create_file_and_commit("test: new file") - with pytest.raises(NoCommitsFoundError) as excinfo: + with pytest.raises( + NoCommitsFoundError, + match=re.escape( + "[NO_COMMITS_FOUND]\nNo commits found to generate a pre-release." + ), + ): util.run_cli("bump", "-pr", "beta") - assert "[NO_COMMITS_FOUND]\nNo commits found to generate a pre-release." in str( - excinfo.value - ) - @pytest.mark.usefixtures("tmp_commitizen_project") def test_bump_with_changelog_to_stdout_arg( @@ -735,14 +736,14 @@ def test_bump_invalid_manual_version_raises_exception( ): util.create_file_and_commit("feat: new file") - with pytest.raises(InvalidManualVersion) as excinfo: + with pytest.raises( + InvalidManualVersion, + match=re.escape( + f"[INVALID_MANUAL_VERSION]\nInvalid manual version: '{manual_version}'" + ), + ): util.run_cli("bump", "--yes", manual_version) - assert ( - f"[INVALID_MANUAL_VERSION]\nInvalid manual version: '{manual_version}'" - in str(excinfo.value) - ) - @pytest.mark.usefixtures("tmp_commitizen_project") @pytest.mark.parametrize( @@ -770,13 +771,12 @@ def test_bump_manual_version(util: UtilFixture, manual_version): @pytest.mark.usefixtures("tmp_commitizen_project") def test_bump_manual_version_disallows_major_version_zero(util: UtilFixture): util.create_file_and_commit("feat: new file") - with pytest.raises(NotAllowed) as excinfo: + with pytest.raises( + NotAllowed, + match="--major-version-zero cannot be combined with MANUAL_VERSION", + ): util.run_cli("bump", "--yes", "--major-version-zero", "0.2.0") - assert "--major-version-zero cannot be combined with MANUAL_VERSION" in str( - excinfo.value - ) - @pytest.mark.parametrize( ("initial_version", "expected_version_after_bump"), diff --git a/tests/commands/test_changelog_command.py b/tests/commands/test_changelog_command.py index b2d024ac7f..469f9e88e1 100644 --- a/tests/commands/test_changelog_command.py +++ b/tests/commands/test_changelog_command.py @@ -861,11 +861,9 @@ def test_changelog_from_rev_range_not_found( util.create_file_and_commit("feat: new file") util.create_tag("1.0.0") - with pytest.raises(NoCommitsFoundError) as excinfo: + with pytest.raises(NoCommitsFoundError, match="Could not find a valid revision"): util.run_cli("changelog", rev_range) # it shouldn't exist - assert "Could not find a valid revision" in str(excinfo) - @pytest.mark.usefixtures("tmp_commitizen_project") def test_changelog_multiple_matching_tags( diff --git a/tests/commands/test_check_command.py b/tests/commands/test_check_command.py index f3f860313d..d587fc0700 100644 --- a/tests/commands/test_check_command.py +++ b/tests/commands/test_check_command.py @@ -73,9 +73,8 @@ def test_check_jira_fails(mocker: MockFixture, util: UtilFixture): mock_path.return_value.read_text.return_value = ( "random message for J-2 #fake_command blah" ) - with pytest.raises(InvalidCommitMessageError) as excinfo: + with pytest.raises(InvalidCommitMessageError, match="commit validation: failed!"): util.run_cli("-n", "cz_jira", "check", "--commit-msg-file", "some_file") - assert "commit validation: failed!" in str(excinfo.value) @pytest.mark.parametrize( @@ -167,30 +166,31 @@ def test_check_a_range_of_git_commits_and_failed(config, mocker: MockFixture): return_value=_build_fake_git_commits(["This commit does not follow rule"]), ) - with pytest.raises(InvalidCommitMessageError) as excinfo: + with pytest.raises( + InvalidCommitMessageError, match="This commit does not follow rule" + ): commands.Check(config=config, arguments={"rev_range": "HEAD~10..master"})() - assert "This commit does not follow rule" in str(excinfo.value) def test_check_command_with_invalid_argument(config): - with pytest.raises(InvalidCommandArgumentError) as excinfo: + with pytest.raises( + InvalidCommandArgumentError, + match="Only one of --rev-range, --message, and --commit-msg-file is permitted by check command!", + ): commands.Check( config=config, arguments={"commit_msg_file": "some_file", "rev_range": "HEAD~10..master"}, ) - assert ( - "Only one of --rev-range, --message, and --commit-msg-file is permitted by check command!" - in str(excinfo.value) - ) @pytest.mark.usefixtures("tmp_commitizen_project") def test_check_command_with_empty_range(config: BaseConfig, util: UtilFixture): # must initialize git with a commit util.create_file_and_commit("feat: initial") - with pytest.raises(NoCommitsFoundError) as excinfo: + with pytest.raises( + NoCommitsFoundError, match="No commit found with range: 'master..master'" + ): commands.Check(config=config, arguments={"rev_range": "master..master"})() - assert "No commit found with range: 'master..master'" in str(excinfo) def test_check_a_range_of_failed_git_commits(config, mocker: MockFixture): @@ -204,9 +204,11 @@ def test_check_a_range_of_failed_git_commits(config, mocker: MockFixture): return_value=_build_fake_git_commits(ill_formatted_commits_msgs), ) - with pytest.raises(InvalidCommitMessageError) as excinfo: + with pytest.raises( + InvalidCommitMessageError, + match=r"[\s\S]*".join(ill_formatted_commits_msgs), + ): commands.Check(config=config, arguments={"rev_range": "HEAD~10..master"})() - assert all([msg in str(excinfo.value) for msg in ill_formatted_commits_msgs]) def test_check_command_with_valid_message(config, success_mock: MockType): @@ -277,9 +279,8 @@ def test_check_command_with_pipe_message_and_failed( ): mocker.patch("sys.stdin", StringIO("bad commit message")) - with pytest.raises(InvalidCommitMessageError) as excinfo: + with pytest.raises(InvalidCommitMessageError, match="commit validation: failed!"): util.run_cli("check") - assert "commit validation: failed!" in str(excinfo.value) def test_check_command_with_comment_in_message_file( @@ -440,11 +441,10 @@ def test_check_command_with_custom_validator_failed( mock_path.return_value.read_text.return_value = ( "123-ABC issue id has wrong format and misses colon" ) - with pytest.raises(InvalidCommitMessageError) as excinfo: + with pytest.raises( + InvalidCommitMessageError, + match=r"commit validation: failed![\s\S]*pattern: ", + ): util.run_cli( "--name", "cz_custom_validator", "check", "--commit-msg-file", "some_file" ) - assert "commit validation: failed!" in str(excinfo.value), ( - "Pattern validation unexpectedly passed" - ) - assert "pattern: " in str(excinfo.value), "Pattern not found in error message" diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index 292530a8da..2474190194 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -1,3 +1,4 @@ +import re import sys from pathlib import Path from unittest.mock import ANY @@ -84,11 +85,11 @@ def test_commit_backup_on_failure( @pytest.mark.usefixtures("staging_is_clean", "commit_mock") def test_commit_retry_fails_no_backup(config): - with pytest.raises(NoCommitBackupError) as excinfo: + with pytest.raises( + NoCommitBackupError, match=re.escape(NoCommitBackupError.message) + ): commands.Commit(config, {"retry": True})() - assert NoCommitBackupError.message in str(excinfo.value) - @pytest.mark.usefixtures("staging_is_clean", "backup_file") def test_commit_retry_works( @@ -209,11 +210,9 @@ def test_commit_command_with_gpgsign_and_always_signoff_enabled( def test_commit_when_nothing_to_commit(config, mocker: MockFixture): mocker.patch("commitizen.git.is_staging_clean", return_value=True) - with pytest.raises(NothingToCommitError) as excinfo: + with pytest.raises(NothingToCommitError, match="No files added to staging!"): commands.Commit(config, {})() - assert "No files added to staging!" in str(excinfo.value) - @pytest.mark.usefixtures("staging_is_clean", "prompt_mock_feat") def test_commit_with_allow_empty(config, success_mock: MockType, commit_mock: MockType): @@ -242,12 +241,9 @@ def test_commit_when_customized_expected_raised(config, mocker: MockFixture): _err = ValueError() _err.__context__ = CzException("This is the root custom err") mocker.patch("questionary.prompt", side_effect=_err) - with pytest.raises(CustomError) as excinfo: + with pytest.raises(CustomError, match="This is the root custom err"): commands.Commit(config, {})() - # Assert only the content in the formatted text - assert "This is the root custom err" in str(excinfo.value) - @pytest.mark.usefixtures("staging_is_clean") def test_commit_when_non_customized_expected_raised(config, mocker: MockFixture): diff --git a/tests/test_bump_hooks.py b/tests/test_bump_hooks.py index 28f0db9440..e73cb49ec1 100644 --- a/tests/test_bump_hooks.py +++ b/tests/test_bump_hooks.py @@ -12,8 +12,8 @@ def test_run(mocker: MockFixture): bump_hooks = ["pre_bump_hook", "pre_bump_hook_1"] cmd_run_mock = mocker.Mock() - cmd_run_mock.return_value.return_code = 0 - mocker.patch.object(cmd, "run_shell", cmd_run_mock) + cmd_run_mock.return_value = 0 + mocker.patch.object(cmd, "run_interactive", cmd_run_mock) hooks.run(bump_hooks) @@ -29,8 +29,8 @@ def test_run_error(mocker: MockFixture): bump_hooks = ["pre_bump_hook", "pre_bump_hook_1"] cmd_run_mock = mocker.Mock() - cmd_run_mock.return_value.return_code = 1 - mocker.patch.object(cmd, "run_shell", cmd_run_mock) + cmd_run_mock.return_value = 1 + mocker.patch.object(cmd, "run_interactive", cmd_run_mock) with pytest.raises(RunHookError): hooks.run(bump_hooks) diff --git a/tests/test_bump_update_version_in_files.py b/tests/test_bump_update_version_in_files.py index 80823a4e1d..d52725b36d 100644 --- a/tests/test_bump_update_version_in_files.py +++ b/tests/test_bump_update_version_in_files.py @@ -1,3 +1,4 @@ +import re from collections.abc import Callable from pathlib import Path from shutil import copyfile @@ -199,7 +200,14 @@ def test_file_version_inconsistent_error( ] old_version = "1.2.3" new_version = "2.0.0" - with pytest.raises(CurrentVersionNotFoundError) as excinfo: + with pytest.raises( + CurrentVersionNotFoundError, + match=re.escape( + f"Current version {old_version} is not found in {inconsistent_python_version_file}.\n" + "The version defined in commitizen configuration and the ones in " + "version_files are possibly inconsistent." + ), + ): bump.update_version_in_files( old_version, new_version, @@ -208,13 +216,6 @@ def test_file_version_inconsistent_error( encoding="utf-8", ) - expected_msg = ( - f"Current version 1.2.3 is not found in {inconsistent_python_version_file}.\n" - "The version defined in commitizen configuration and the ones in " - "version_files are possibly inconsistent." - ) - assert expected_msg in str(excinfo.value) - def test_multiple_versions_to_bump( multiple_versions_to_update_poetry_lock, file_regression @@ -285,7 +286,14 @@ def test_update_version_in_files_with_check_consistency_true_failure( version_files = [commitizen_config_file, inconsistent_python_version_file] # This should fail because inconsistent_python_version_file doesn't contain the current version - with pytest.raises(CurrentVersionNotFoundError) as excinfo: + with pytest.raises( + CurrentVersionNotFoundError, + match=re.escape( + f"Current version {old_version} is not found in {inconsistent_python_version_file}.\n" + "The version defined in commitizen configuration and the ones in " + "version_files are possibly inconsistent." + ), + ): bump.update_version_in_files( old_version, new_version, @@ -294,13 +302,6 @@ def test_update_version_in_files_with_check_consistency_true_failure( encoding="utf-8", ) - expected_msg = ( - f"Current version {old_version} is not found in {inconsistent_python_version_file}.\n" - "The version defined in commitizen configuration and the ones in " - "version_files are possibly inconsistent." - ) - assert expected_msg in str(excinfo.value) - @pytest.mark.parametrize( ("encoding", "filename"), diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 11c3a60446..24ec3df979 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -1228,11 +1228,11 @@ def test_generate_ordered_changelog_tree(change_type_order, expected_reordering) def test_generate_ordered_changelog_tree_raises(): change_type_order = ["BREAKING CHANGE", "feat", "refactor", "feat"] - with pytest.raises(InvalidConfigurationError) as excinfo: + with pytest.raises( + InvalidConfigurationError, match="Change types contain duplicated types" + ): list(changelog.generate_ordered_changelog_tree(COMMITS_TREE, change_type_order)) - assert "Change types contain duplicated types" in str(excinfo) - def test_render_changelog( gitcommits, tags, changelog_content, any_changelog_format: ChangelogFormat @@ -1543,9 +1543,10 @@ def test_get_next_tag_name_after_version(tags): assert last_tag_name is None # Test error when version not found - with pytest.raises(changelog.NoCommitsFoundError) as exc_info: + with pytest.raises( + changelog.NoCommitsFoundError, match="Could not find a valid revision range" + ): changelog.get_next_tag_name_after_version(tags, "nonexistent") - assert "Could not find a valid revision range" in str(exc_info.value) @dataclass diff --git a/tests/test_cli.py b/tests/test_cli.py index c1f6d5beda..b04dd9b988 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -31,9 +31,28 @@ def test_no_argv(util: UtilFixture, capsys, file_regression): "arg", [ "--invalid-arg", - "invalidCommand", + 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." + ), +) @pytest.mark.usefixtures("python_version", "consistent_terminal_output") def test_invalid_command(util: UtilFixture, capsys, file_regression, arg): with pytest.raises(NoCommandFoundError): @@ -43,16 +62,14 @@ def test_invalid_command(util: UtilFixture, capsys, file_regression, arg): file_regression.check(err, extension=".txt") -def test_cz_config_file_without_correct_file_path(util: UtilFixture, capsys): - with pytest.raises(ConfigFileNotFound) as excinfo: +def test_cz_config_file_without_correct_file_path(util: UtilFixture): + with pytest.raises(ConfigFileNotFound, match="Cannot found the config file"): util.run_cli("--config", "./config/pyproject.toml", "example") - assert "Cannot found the config file" in str(excinfo.value) def test_cz_with_arg_but_without_command(util: UtilFixture): - with pytest.raises(NoCommandFoundError) as excinfo: + with pytest.raises(NoCommandFoundError, match="Command is required"): util.run_cli("--name", "cz_jira") - assert "Command is required" in str(excinfo.value) def test_name(util: UtilFixture, capsys): @@ -70,7 +87,7 @@ def test_name_default_value(util: UtilFixture, capsys): def test_ls(util: UtilFixture, capsys): util.run_cli("-n", "cz_jira", "ls") - out, err = capsys.readouterr() + out, _ = capsys.readouterr() assert "cz_conventional_commits" in out assert isinstance(out, str) @@ -85,7 +102,7 @@ def test_arg_debug(util: UtilFixture): assert excepthook.keywords.get("debug") is True -def test_commitizen_excepthook(capsys): +def test_commitizen_excepthook(): with pytest.raises(SystemExit) as excinfo: cli.commitizen_excepthook(NotAGitProjectError, NotAGitProjectError(), "") @@ -93,7 +110,7 @@ def test_commitizen_excepthook(capsys): assert excinfo.value.code == NotAGitProjectError.exit_code -def test_commitizen_debug_excepthook(capsys): +def test_commitizen_debug_excepthook(): with pytest.raises(SystemExit) as excinfo: cli.commitizen_excepthook( NotAGitProjectError, @@ -102,7 +119,6 @@ def test_commitizen_debug_excepthook(capsys): debug=True, ) - assert excinfo.type is SystemExit assert excinfo.value.code == NotAGitProjectError.exit_code assert "NotAGitProjectError" in str(excinfo.traceback[0]) @@ -119,12 +135,10 @@ def test_argcomplete_activation(): Equivalent to run: $ eval "$(register-python-argcomplete pytest)" """ - output = subprocess.run(["register-python-argcomplete", "cz"]) + subprocess.run(["register-python-argcomplete", "cz"], check=True) - assert output.returncode == 0 - -def test_commitizen_excepthook_no_raises(capsys): +def test_commitizen_excepthook_no_raises(): with pytest.raises(SystemExit) as excinfo: cli.commitizen_excepthook( NotAGitProjectError, @@ -165,17 +179,18 @@ def test_parse_no_raise(input_str, expected_result): def test_unknown_args_raises(util: UtilFixture): - with pytest.raises(InvalidCommandArgumentError) as excinfo: + with pytest.raises( + InvalidCommandArgumentError, match="Invalid commitizen arguments were found" + ): util.run_cli("c", "-this_arg_is_not_supported") - assert "Invalid commitizen arguments were found" in str(excinfo.value) def test_unknown_args_before_double_dash_raises(util: UtilFixture): - with pytest.raises(InvalidCommandArgumentError) as excinfo: + with pytest.raises( + InvalidCommandArgumentError, + match="Invalid commitizen arguments were found before -- separator", + ): util.run_cli("c", "-this_arg_is_not_supported", "--") - assert "Invalid commitizen arguments were found before -- separator" in str( - excinfo.value - ) def test_commitizen_excepthook_non_commitizen_exception(mocker: MockFixture): diff --git a/tests/test_cmd.py b/tests/test_cmd.py index 734c900919..c462067a53 100644 --- a/tests/test_cmd.py +++ b/tests/test_cmd.py @@ -57,39 +57,161 @@ def decode(self, encoding="utf-8", errors="strict"): cmd._try_decode(_bytes()) -def test_run_returns_command_with_shell_false(): - """Test that cmd.run executes a list-based command without shell.""" - c = cmd.run(["python", "-c", "print('hello')"]) - assert c.return_code == 0 - assert "hello" in c.out - - -def test_run_shell_returns_command_with_shell_true(): - """Test that cmd.run_shell executes a string command via the shell.""" - c = cmd.run_shell("python -c \"print('hello')\"") - assert c.return_code == 0 - assert "hello" in c.out - - -def test_run_with_env(): - """Test that cmd.run passes extra environment variables.""" - c = cmd.run( - ["python", "-c", "import os; print(os.environ['CZ_TEST_VAR'])"], - env={"CZ_TEST_VAR": "test_value"}, - ) - assert c.return_code == 0 - assert "test_value" in c.out - - -def test_run_with_string_emits_deprecation_warning(): - """Test that passing a string to cmd.run() emits a DeprecationWarning.""" - import warnings - - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - c = cmd.run("python -c \"print('deprecated')\"") +class TestRun: + def test_returns_command_with_shell_false(self): + """Test that cmd.run executes a list-based command without shell.""" + c = cmd.run(["python", "-c", "print('hello')"]) + assert c.return_code == 0 + assert "hello" in c.out + + def test_with_env(self): + """Test that cmd.run passes extra environment variables.""" + c = cmd.run( + ["python", "-c", "import os; print(os.environ['CZ_TEST_VAR'])"], + env={"CZ_TEST_VAR": "test_value"}, + ) + assert c.return_code == 0 + assert "test_value" in c.out + + def test_with_string_emits_deprecation_warning(self): + """Test that passing a string to cmd.run() emits a DeprecationWarning.""" + import warnings + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + c = cmd.run("python -c \"print('deprecated')\"") + assert c.return_code == 0 + assert "deprecated" in c.out + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "cmd.run()" in str(w[0].message) + + def test_stdout_captured(self): + result = cmd.run(["python", "-c", "print('hello')"]) + assert "hello" in result.out + assert isinstance(result.stdout, bytes) + assert b"hello" in result.stdout + + def test_stderr_captured(self): + result = cmd.run( + ["python", "-c", "import sys; print('err msg', file=sys.stderr)"] + ) + assert "err msg" in result.err + assert isinstance(result.stderr, bytes) + assert b"err msg" in result.stderr + + def test_zero_return_code_on_success(self): + result = cmd.run(["python", "-c", "import sys; sys.exit(0)"]) + assert result.return_code == 0 + + def test_nonzero_return_code_on_failure(self): + result = cmd.run(["python", "-c", "import sys; sys.exit(42)"]) + assert result.return_code == 42 + + def test_env_passed_to_subprocess(self): + result = cmd.run( + ["python", "-c", "import os; print(os.environ['CZ_TEST_VAR'])"], + env={"CZ_TEST_VAR": "sentinelvalue"}, + ) + assert "sentinelvalue" in result.out + assert result.return_code == 0 + + def test_env_merged_with_os_environ(self, monkeypatch): + monkeypatch.setenv("CZ_EXISTING_VAR", "fromenv") + result = cmd.run( + ["python", "-c", "import os; print(os.environ['CZ_EXISTING_VAR'])"], + env={"CZ_EXTRA_VAR": "extra"}, + ) + assert "fromenv" in result.out + + def test_empty_stdout_and_stderr(self): + result = cmd.run(["python", "-c", '"pass"']) + assert result.out == "" + assert result.err == "" + assert result.stdout == b"" + assert result.stderr == b"" + + def test_no_env_uses_os_environ(self, monkeypatch): + monkeypatch.setenv("CZ_NO_ENV_TEST", "inherited") + result = cmd.run( + ["python", "-c", "import os; print(os.environ['CZ_NO_ENV_TEST'])"] + ) + assert "inherited" in result.out + + +class TestRunShell: + def test_returns_command_with_shell_true(self): + """Test that cmd.run_shell executes a string command via the shell.""" + c = cmd.run_shell("python -c \"print('hello')\"") assert c.return_code == 0 - assert "deprecated" in c.out - assert len(w) == 1 - assert issubclass(w[0].category, DeprecationWarning) - assert "cmd.run()" in str(w[0].message) + assert "hello" in c.out + + +class TestRunInteractive: + def test_zero_return_code_on_success(self): + return_code = cmd.run_interactive(["python", "-c", "import sys; sys.exit(0)"]) + assert return_code == 0 + + def test_nonzero_return_code_on_failure(self): + return_code = cmd.run_interactive(["python", "-c", "import sys; sys.exit(3)"]) + assert return_code == 3 + + def test_env_passed_to_subprocess(self): + return_code = cmd.run_interactive( + [ + "python", + "-c", + "import os, sys; sys.exit(0 if os.environ['CZ_ITEST_VAR'] == 'val' else 1)", + ], + env={"CZ_ITEST_VAR": "val"}, + ) + assert return_code == 0 + + def test_env_merged_with_os_environ(self, monkeypatch): + monkeypatch.setenv("CZ_ITEST_EXISTING", "yes") + return_code = cmd.run_interactive( + [ + "python", + "-c", + "import os, sys; sys.exit(0 if os.environ['CZ_ITEST_EXISTING'] == 'yes' else 1)", + ], + env={"CZ_ITEST_EXTRA": "extra"}, + ) + assert return_code == 0 + + def test_runs_with_string(self): + """Test that passing a string to cmd.run_interactive emits a DeprecationWarning.""" + import warnings + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + return_code = cmd.run_interactive("python -c \"print('hello')\"") + assert return_code == 0 + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "cmd.run_interactive()" in str(w[0].message) + + +class TestRunInteractiveShell: + def test_zero_return_code_on_success(self): + return_code = cmd.run_interactive_shell('python -c "import sys; sys.exit(0)"') + assert return_code == 0 + + def test_nonzero_return_code_on_failure(self): + return_code = cmd.run_interactive_shell('python -c "import sys; sys.exit(3)"') + assert return_code == 3 + + def test_env_passed_to_subprocess(self): + return_code = cmd.run_interactive_shell( + "python -c \"import os, sys; sys.exit(0 if os.environ['CZ_ITEST_VAR'] == 'val' else 1)\"", + env={"CZ_ITEST_VAR": "val"}, + ) + assert return_code == 0 + + def test_env_merged_with_os_environ(self, monkeypatch): + monkeypatch.setenv("CZ_ITEST_EXISTING", "yes") + return_code = cmd.run_interactive_shell( + "python -c \"import os, sys; sys.exit(0 if os.environ['CZ_ITEST_EXISTING'] == 'yes' else 1)\"", + env={"CZ_ITEST_EXTRA": "extra"}, + ) + assert return_code == 0 diff --git a/tests/test_conf.py b/tests/test_conf.py index c004e96e11..c01b96d38b 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -2,6 +2,7 @@ import json import os +import re from pathlib import Path from typing import Any @@ -440,9 +441,8 @@ def test_init_with_invalid_config_content(self, tmp_path, config_file): path = tmp_path / "commitizen" / config_file path.parent.mkdir(parents=True, exist_ok=True) - with pytest.raises(InvalidConfigurationError) as excinfo: + with pytest.raises(InvalidConfigurationError, match=re.escape(config_file)): TomlConfig(data=existing_content, path=path) - assert config_file in str(excinfo.value) @pytest.mark.parametrize( @@ -467,9 +467,8 @@ def test_init_with_invalid_config_content(self, tmp_path, config_file): path = tmp_path / "commitizen" / config_file path.parent.mkdir(parents=True, exist_ok=True) - with pytest.raises(InvalidConfigurationError) as excinfo: + with pytest.raises(InvalidConfigurationError, match=re.escape(config_file)): JsonConfig(data=existing_content, path=path) - assert config_file in str(excinfo.value) @pytest.mark.parametrize( @@ -494,6 +493,5 @@ def test_init_with_invalid_content(self, tmp_path, config_file): path = tmp_path / "commitizen" / config_file path.parent.mkdir(parents=True, exist_ok=True) - with pytest.raises(InvalidConfigurationError) as excinfo: + with pytest.raises(InvalidConfigurationError, match=re.escape(config_file)): YAMLConfig(data=existing_content, path=path) - assert config_file in str(excinfo.value) diff --git a/tests/test_cz_customize.py b/tests/test_cz_customize.py index 311eea19a9..726177247b 100644 --- a/tests/test_cz_customize.py +++ b/tests/test_cz_customize.py @@ -1,3 +1,4 @@ +import re from pathlib import Path import pytest @@ -377,11 +378,12 @@ def config_with_unicode(request): def test_initialize_cz_customize_failed(): config = BaseConfig() - with pytest.raises(MissingCzCustomizeConfigError) as excinfo: + with pytest.raises( + MissingCzCustomizeConfigError, + match=re.escape(MissingCzCustomizeConfigError.message), + ): CustomizeCommitsCz(config) - assert MissingCzCustomizeConfigError.message in str(excinfo.value) - def test_bump_pattern(config): cz = CustomizeCommitsCz(config) @@ -412,6 +414,86 @@ def test_bump_map_unicode(config_with_unicode): } +def test_bump_map_major_version_zero_is_derived_from_bump_map(): + """Regression test for #1728: when the user provides ``bump_map`` but no + explicit ``bump_map_major_version_zero``, the latter is derived from the + former (``MAJOR`` → ``MINOR``) instead of falling through to the default + ``defaults.BUMP_MAP_MAJOR_VERSION_ZERO``.""" + config = BaseConfig() + config.settings.update( + { + "name": "cz_customize", + "customize": { + "bump_pattern": r"^(feat|fix|docs)", + "bump_map": { + "break": "MAJOR", + "feat": "PATCH", + "docs": "PATCH", + }, + }, + } + ) + + cz = CustomizeCommitsCz(config) + + # Same patterns, MAJOR demoted to MINOR. + assert dict(cz.bump_map_major_version_zero) == { + "break": "MINOR", + "feat": "PATCH", + "docs": "PATCH", + } + + +def test_bump_map_major_version_zero_explicit_user_value_wins(): + """If the user explicitly sets ``bump_map_major_version_zero``, that + value is used verbatim (no derivation).""" + config = BaseConfig() + config.settings.update( + { + "name": "cz_customize", + "customize": { + "bump_pattern": r"^(feat|fix|docs)", + "bump_map": { + "break": "MAJOR", + "feat": "PATCH", + }, + "bump_map_major_version_zero": { + "break": "MAJOR", # NB: kept as MAJOR + "feat": "PATCH", + }, + }, + } + ) + + cz = CustomizeCommitsCz(config) + + assert dict(cz.bump_map_major_version_zero) == { + "break": "MAJOR", + "feat": "PATCH", + } + + +def test_bump_map_major_version_zero_falls_back_to_defaults_without_bump_map(): + """When the user provides neither ``bump_map`` nor + ``bump_map_major_version_zero``, the class default applies.""" + from commitizen import defaults + + config = BaseConfig() + config.settings.update( + { + "name": "cz_customize", + "customize": { + # No bump_map, no bump_map_major_version_zero. + "schema_pattern": r"^(feat|fix): (.*)$", + }, + } + ) + + cz = CustomizeCommitsCz(config) + + assert cz.bump_map_major_version_zero is defaults.BUMP_MAP_MAJOR_VERSION_ZERO + + def test_change_type_order(config): cz = CustomizeCommitsCz(config) assert cz.change_type_order == [ diff --git a/tests/test_factory.py b/tests/test_factory.py index 303ae4e728..ea58680180 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -1,3 +1,4 @@ +import re import sys from importlib import metadata from textwrap import dedent @@ -31,11 +32,12 @@ def test_factory(): def test_factory_fails(): config = BaseConfig() config.settings.update({"name": "Nothing"}) - with pytest.raises(NoCommitizenFoundException) as excinfo: + with pytest.raises( + NoCommitizenFoundException, + match=re.escape("The committer has not been found in the system."), + ): factory.committer_factory(config) - assert "The committer has not been found in the system." in str(excinfo) - def test_discover_plugins(tmp_path): legacy_plugin_folder = tmp_path / "cz_legacy" diff --git a/tests/test_git.py b/tests/test_git.py index 51586eb22b..db0ce4039b 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -413,9 +413,8 @@ def test_get_filenames_in_commit_with_git_reference(util: UtilFixture): def test_get_filenames_in_commit_error(util: UtilFixture): """Test that GitCommandError is raised when git command fails.""" util.mock_cmd(err="fatal: bad object HEAD", return_code=1) - with pytest.raises(GitCommandError) as excinfo: + with pytest.raises(GitCommandError, match="fatal: bad object HEAD"): git.get_filenames_in_commit() - assert str(excinfo.value) == "fatal: bad object HEAD" @pytest.mark.parametrize( diff --git a/tests/test_tags.py b/tests/test_tags.py index 2471b8461b..3d5f8fc351 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -99,3 +99,44 @@ def test_find_tag_for_partial_version_ignores_invalid_tags(): assert found is not None assert found.name == "1.2.1" + + +def test_is_version_tag_accepts_semver2_prerelease_in_custom_tag_format(): + """Regression test for #1614: a SemVer2-style prerelease segment such as + ``rc.0`` (with a literal dot) must be recognised when it appears at the + position of ``${prerelease}`` in a custom ``tag_format``. Before the + prerelease regex was widened from ``\\w+\\d+`` to ``\\w+(?:\\.\\w+)*``, + the tag commitizen itself just created emitted "Invalid version tag" + warnings on the next changelog/bump. + """ + from commitizen.version_schemes import get_version_scheme + + scheme = get_version_scheme({"version_scheme": "semver2"}) + rules = TagRules( + scheme=scheme, + tag_format="${major}.${minor}-${patch}${prerelease}", + ) + + assert rules.is_version_tag("0.0-2rc.0") is True + # Plain releases (no prerelease) are still accepted. + assert rules.is_version_tag("0.0-2") is True + # Multi-segment SemVer2 prereleases too. + assert rules.is_version_tag("0.0-2alpha.beta.1") is True + + # And ``extract_version`` round-trips the prerelease portion. + extracted = rules.extract_version(_git_tag("0.0-2rc.0")) + assert str(extracted) == "0.0.2-rc.0" + + +def test_is_version_tag_accepts_dotless_devrelease_in_custom_tag_format(): + """Regression test for #1614: ``${devrelease}`` accepts both ``dev1`` + and ``.dev1`` suffixes when a custom ``tag_format`` splits release and dev + portions explicitly. + """ + rules = TagRules(tag_format="version-${major}.${minor}.${patch}${devrelease}") + + assert rules.is_version_tag("version-1.2.3.dev1") is True + assert rules.is_version_tag("version-1.2.3dev1") is True + + extracted = rules.extract_version(_git_tag("version-1.2.3dev1")) + assert str(extracted) == "1.2.3.dev1" diff --git a/uv.lock b/uv.lock index 1fe549948c..acfbcc1ffd 100644 --- a/uv.lock +++ b/uv.lock @@ -48,6 +48,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/f8/d02f650c47d05034dcd6f9c8cf94f39598b7a89c00ecda0ecb2911bc27e9/backrefs-6.2-py39-none-any.whl", hash = "sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7", size = 381077, upload-time = "2026-02-16T19:10:13.74Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + [[package]] name = "cachetools" version = "7.0.5" @@ -203,7 +216,7 @@ wheels = [ [[package]] name = "commitizen" -version = "4.15.1" +version = "4.16.2" source = { editable = "." } dependencies = [ { name = "argcomplete" }, @@ -223,6 +236,7 @@ dependencies = [ [package.dev-dependencies] base = [ + { name = "mkdocs-llmstxt" }, { name = "poethepoet" }, ] dev = [ @@ -231,6 +245,7 @@ dev = [ { name = "ipython", version = "9.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "mkdocs" }, { name = "mkdocs-git-revision-date-localized-plugin" }, + { name = "mkdocs-llmstxt" }, { name = "mkdocs-material" }, { name = "mypy" }, { name = "poethepoet" }, @@ -301,11 +316,15 @@ requires-dist = [ ] [package.metadata.requires-dev] -base = [{ name = "poethepoet", specifier = ">=0.34.0" }] +base = [ + { name = "mkdocs-llmstxt", specifier = ">=0.5.0" }, + { name = "poethepoet", specifier = ">=0.34.0" }, +] dev = [ { name = "ipython", specifier = ">=8.0" }, { name = "mkdocs", specifier = ">=1.4.2,<2" }, { name = "mkdocs-git-revision-date-localized-plugin", specifier = ">=1.5.0" }, + { name = "mkdocs-llmstxt", specifier = ">=0.5.0" }, { name = "mkdocs-material", specifier = ">=9.1.6" }, { name = "mypy", specifier = ">=1.16.0" }, { name = "poethepoet", specifier = ">=0.34.0" }, @@ -589,14 +608,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.47" +version = "3.1.50" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/bd/50db468e9b1310529a19fce651b3b0e753b5c07954d486cba31bbee9a5d5/gitpython-3.1.47.tar.gz", hash = "sha256:dba27f922bd2b42cb54c87a8ab3cb6beb6bf07f3d564e21ac848913a05a8a3cd", size = 216978, upload-time = "2026-04-22T02:44:44.059Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/c5/a1bc0996af85757903cf2bf444a7824e68e0035ce63fb41d6f76f9def68b/gitpython-3.1.47-py3-none-any.whl", hash = "sha256:489f590edfd6d20571b2c0e72c6a6ac6915ee8b8cd04572330e3842207a78905", size = 209547, upload-time = "2026-04-22T02:44:41.271Z" }, + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, ] [[package]] @@ -832,14 +851,27 @@ wheels = [ [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "3.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markdownify" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816, upload-time = "2025-11-16T19:21:18.565Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724, upload-time = "2025-11-16T19:21:17.622Z" }, ] [[package]] @@ -939,6 +971,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] +[[package]] +name = "mdformat" +version = "0.7.22" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/eb/b5cbf2484411af039a3d4aeb53a5160fae25dd8c84af6a4243bc2f3fedb3/mdformat-0.7.22.tar.gz", hash = "sha256:eef84fa8f233d3162734683c2a8a6222227a229b9206872e6139658d99acb1ea", size = 34610, upload-time = "2025-01-30T18:00:51.418Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/6f/94a7344f6d634fe3563bea8b33bccedee37f2726f7807e9a58440dc91627/mdformat-0.7.22-py3-none-any.whl", hash = "sha256:61122637c9e1d9be1329054f3fa216559f0d1f722b7919b060a8c2a4ae1850e5", size = 34447, upload-time = "2025-01-30T18:00:48.708Z" }, +] + +[[package]] +name = "mdformat-tables" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdformat" }, + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/fc/995ba209096bdebdeb8893d507c7b32b7e07d9a9f2cdc2ec07529947794b/mdformat_tables-1.0.0.tar.gz", hash = "sha256:a57db1ac17c4a125da794ef45539904bb8a9592e80557d525e1f169c96daa2c8", size = 6106, upload-time = "2024-08-23T23:41:33.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/37/d78e37d14323da3f607cd1af7daf262cb87fe614a245c15ad03bb03a2706/mdformat_tables-1.0.0-py3-none-any.whl", hash = "sha256:94cd86126141b2adc3b04c08d1441eb1272b36c39146bab078249a41c7240a9a", size = 5104, upload-time = "2024-08-23T23:41:31.863Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1010,6 +1068,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/3f/4f663fb7e889fbb2fabef7a67ddd96f8355edca917aa724c6c6cda352d01/mkdocs_git_revision_date_localized_plugin-1.5.1-py3-none-any.whl", hash = "sha256:b00fd36ed0f9b2326b1488fd8fa31bf2ce64e68c4aa60a9ce857f10719571903", size = 26150, upload-time = "2026-01-26T13:34:28.768Z" }, ] +[[package]] +name = "mkdocs-llmstxt" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "markdownify" }, + { name = "mdformat" }, + { name = "mdformat-tables" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f5/4c31cdffa7c09bf48d8c7a50d8342dc100abac98ac4150826bc11afc0c9f/mkdocs_llmstxt-0.5.0.tar.gz", hash = "sha256:b2fa9e6d68df41d7467e948a4745725b6c99434a36b36204857dbd7bb3dfe041", size = 33909, upload-time = "2025-11-20T14:02:24.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/2b/82928cc9e8d9269cd79e7ebf015efdc4945e6c646e86ec1d4dba1707f215/mkdocs_llmstxt-0.5.0-py3-none-any.whl", hash = "sha256:753c699913d2d619a9072604b26b6dc9f5fb6d257d9b107857f80c8a0b787533", size = 12040, upload-time = "2025-11-20T14:02:23.483Z" }, +] + [[package]] name = "mkdocs-material" version = "9.7.6" @@ -1603,6 +1676,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, ] +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + [[package]] name = "stack-data" version = "0.6.3" @@ -1821,11 +1903,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]]