From 2626e011a9d9ac9691f75ce44a7566e8aa7c6304 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Mon, 15 Sep 2025 19:44:16 +0800 Subject: [PATCH 01/33] refactor(BaseCommitizen): construct Style object directly to get rid of potential type error --- commitizen/cz/base.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py index cdc147669..75981f074 100644 --- a/commitizen/cz/base.py +++ b/commitizen/cz/base.py @@ -5,7 +5,7 @@ from typing import Any, Callable, Protocol from jinja2 import BaseLoader, PackageLoader -from prompt_toolkit.styles import Style, merge_styles +from prompt_toolkit.styles import Style from commitizen import git from commitizen.config.base_config import BaseConfig @@ -77,12 +77,12 @@ def message(self, answers: Mapping[str, Any]) -> str: @property def style(self) -> Style: - return merge_styles( + return Style( [ - Style(BaseCommitizen.default_style_config), - Style(self.config.settings["style"]), + *BaseCommitizen.default_style_config, + *self.config.settings["style"], ] - ) # type: ignore[return-value] + ) def example(self) -> str: """Example of the commit message.""" From 4d1285b6d8587e5f6685c087c56f0e792ea77f6e Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Mon, 15 Sep 2025 20:04:10 +0800 Subject: [PATCH 02/33] refactor(BaseCommitizen): remove NotImplementedError and make them abstract method --- commitizen/cz/base.py | 8 ++++---- tests/conftest.py | 24 ++++++++++++++++++++++ tests/test_cz_base.py | 46 ------------------------------------------- 3 files changed, 28 insertions(+), 50 deletions(-) delete mode 100644 tests/test_cz_base.py diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py index 75981f074..ecb1a2196 100644 --- a/commitizen/cz/base.py +++ b/commitizen/cz/base.py @@ -84,18 +84,18 @@ def style(self) -> Style: ] ) + @abstractmethod def example(self) -> str: """Example of the commit message.""" - raise NotImplementedError("Not Implemented yet") + @abstractmethod def schema(self) -> str: """Schema definition of the commit message.""" - raise NotImplementedError("Not Implemented yet") + @abstractmethod def schema_pattern(self) -> str: """Regex matching the schema used for message validation.""" - raise NotImplementedError("Not Implemented yet") + @abstractmethod def info(self) -> str: """Information about the standardized commit message.""" - raise NotImplementedError("Not Implemented yet") diff --git a/tests/conftest.py b/tests/conftest.py index 324ef9beb..61b64ae8d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -215,6 +215,18 @@ def message(self, answers: Mapping) -> str: subject = answers.get("subject", "default message").trim() return f"{prefix}: {subject}" + def example(self) -> str: + return "" + + def schema(self) -> str: + return "" + + def schema_pattern(self) -> str: + return "" + + def info(self) -> str: + return "" + @pytest.fixture() def use_cz_semver(mocker): @@ -229,6 +241,18 @@ def questions(self) -> list[CzQuestion]: def message(self, answers: Mapping) -> str: return "" + def example(self) -> str: + return "" + + def schema(self) -> str: + return "" + + def schema_pattern(self) -> str: + return "" + + def info(self) -> str: + return "" + @pytest.fixture def mock_plugin(mocker: MockerFixture, config: BaseConfig) -> BaseCommitizen: diff --git a/tests/test_cz_base.py b/tests/test_cz_base.py deleted file mode 100644 index 0ee5a23fb..000000000 --- a/tests/test_cz_base.py +++ /dev/null @@ -1,46 +0,0 @@ -from collections.abc import Mapping - -import pytest - -from commitizen.cz.base import BaseCommitizen - - -class DummyCz(BaseCommitizen): - def questions(self): - return [{"type": "input", "name": "commit", "message": "Initial commit:\n"}] - - def message(self, answers: Mapping): - return answers["commit"] - - -def test_base_raises_error(config): - with pytest.raises(TypeError): - BaseCommitizen(config) - - -def test_questions(config): - cz = DummyCz(config) - assert isinstance(cz.questions(), list) - - -def test_message(config): - cz = DummyCz(config) - assert cz.message({"commit": "holis"}) == "holis" - - -def test_example(config): - cz = DummyCz(config) - with pytest.raises(NotImplementedError): - cz.example() - - -def test_schema(config): - cz = DummyCz(config) - with pytest.raises(NotImplementedError): - cz.schema() - - -def test_info(config): - cz = DummyCz(config) - with pytest.raises(NotImplementedError): - cz.info() From fca28c0ce9b94a9a1b97e72504b7d88bc9bff995 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Tue, 16 Sep 2025 21:21:53 +0800 Subject: [PATCH 03/33] style(UvProvider): fix typo in comment --- commitizen/providers/uv_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commitizen/providers/uv_provider.py b/commitizen/providers/uv_provider.py index 36c8a49ad..21e8322d9 100644 --- a/commitizen/providers/uv_provider.py +++ b/commitizen/providers/uv_provider.py @@ -13,7 +13,7 @@ class UvProvider(TomlProvider): """ - uv.lock and pyproject.tom version management + uv.lock and pyproject.toml version management """ filename = "pyproject.toml" From 549962859323ac49ac3bc6f2e9a0b5653fdc571a Mon Sep 17 00:00:00 2001 From: Christian Heissenberger Date: Fri, 5 Sep 2025 20:20:52 +0200 Subject: [PATCH 04/33] feat: allow `amend!` prefix as created by `git --fixup=reword:` https://git-scm.com/docs/git-commit/2.32.0#Documentation/git-commit.txt---fixupamendrewordcommit --- commitizen/defaults.py | 1 + tests/commands/test_check_command.py | 8 ++++++++ tests/test_conf.py | 18 ++++++++++++++++-- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/commitizen/defaults.py b/commitizen/defaults.py index 94d4d97b2..4840a69b6 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -92,6 +92,7 @@ class Settings(TypedDict, total=False): "Pull request", "fixup!", "squash!", + "amend!", ], "changelog_file": "CHANGELOG.md", "changelog_format": None, # default guessed from changelog_file diff --git a/tests/commands/test_check_command.py b/tests/commands/test_check_command.py index d95a173d8..365a556dd 100644 --- a/tests/commands/test_check_command.py +++ b/tests/commands/test_check_command.py @@ -452,3 +452,11 @@ def test_check_command_with_message_length_limit_exceeded(config, mocker: MockFi with pytest.raises(InvalidCommitMessageError): check_cmd() error_mock.assert_called_once() + + +def test_check_command_with_amend_prefix_default(config, mocker: MockFixture): + success_mock = mocker.patch("commitizen.out.success") + check_cmd = commands.Check(config=config, arguments={"message": "amend! test"}) + + check_cmd() + success_mock.assert_called_once() diff --git a/tests/test_conf.py b/tests/test_conf.py index f89a0049f..cc38bf4b0 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -77,7 +77,14 @@ "bump_message": None, "retry_after_failure": False, "allow_abort": False, - "allowed_prefixes": ["Merge", "Revert", "Pull request", "fixup!", "squash!"], + "allowed_prefixes": [ + "Merge", + "Revert", + "Pull request", + "fixup!", + "squash!", + "amend!", + ], "version_files": ["commitizen/__version__.py", "pyproject.toml"], "style": [["pointer", "reverse"], ["question", "underline"]], "changelog_file": "CHANGELOG.md", @@ -108,7 +115,14 @@ "bump_message": None, "retry_after_failure": False, "allow_abort": False, - "allowed_prefixes": ["Merge", "Revert", "Pull request", "fixup!", "squash!"], + "allowed_prefixes": [ + "Merge", + "Revert", + "Pull request", + "fixup!", + "squash!", + "amend!", + ], "version_files": ["commitizen/__version__.py", "pyproject.toml"], "style": [["pointer", "reverse"], ["question", "underline"]], "changelog_file": "CHANGELOG.md", From 475f3f2a6776420bf9e58c17ca63adc45b3bcb04 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Mon, 15 Sep 2025 13:25:40 +0800 Subject: [PATCH 05/33] fix(Init): raise InitFailedError on keyboard interrupt on pre-commit hook question, simplify logic, remove unreachable code path --- commitizen/commands/init.py | 82 ++++++++++++----------------- tests/commands/test_init_command.py | 29 ++-------- 2 files changed, 37 insertions(+), 74 deletions(-) diff --git a/commitizen/commands/init.py b/commitizen/commands/init.py index 2ce3981f4..92e7d06d7 100644 --- a/commitizen/commands/init.py +++ b/commitizen/commands/init.py @@ -150,9 +150,43 @@ def __call__(self) -> None: tag_format = self._ask_tag_format(tag) # confirm & text update_changelog_on_bump = self._ask_update_changelog_on_bump() # confirm major_version_zero = self._ask_major_version_zero(version) # confirm + hook_types: list[str] | None = questionary.checkbox( + "What types of pre-commit hook you want to install? (Leave blank if you don't want to install)", + choices=[ + questionary.Choice("commit-msg", checked=False), + questionary.Choice("pre-push", checked=False), + ], + ).unsafe_ask() except KeyboardInterrupt: raise InitFailedError("Stopped by user") + if hook_types: + config_data = self._get_config_data() + with smart_open( + self._PRE_COMMIT_CONFIG_PATH, "w", encoding=self.encoding + ) as config_file: + yaml.safe_dump(config_data, stream=config_file) + + if not self.project_info.is_pre_commit_installed: + raise InitFailedError( + "Failed to install pre-commit hook.\n" + "pre-commit is not installed in current environment." + ) + + cmd_str = "pre-commit install " + " ".join( + f"--hook-type {ty}" for ty in hook_types + ) + c = cmd.run(cmd_str) + if c.return_code != 0: + raise InitFailedError( + "Failed to install pre-commit hook.\n" + f"Error running {cmd_str}." + "Outputs are attached below:\n" + f"stdout: {c.out}\n" + f"stderr: {c.err}" + ) + out.write("commitizen pre-commit hook is now installed in your '.git'\n") + # Initialize configuration if "toml" in config_path: self.config = TomlConfig(data="", path=config_path) @@ -161,20 +195,6 @@ def __call__(self) -> None: elif "yaml" in config_path: self.config = YAMLConfig(data="", path=config_path) - # Collect hook data - hook_types = questionary.checkbox( - "What types of pre-commit hook you want to install? (Leave blank if you don't want to install)", - choices=[ - questionary.Choice("commit-msg", checked=False), - questionary.Choice("pre-push", checked=False), - ], - ).unsafe_ask() - if hook_types: - try: - self._install_pre_commit_hook(hook_types) - except InitFailedError as e: - raise InitFailedError(f"Failed to install pre-commit hook.\n{e}") - # Create and initialize config self.config.init_empty_config_content() @@ -321,26 +341,6 @@ def _ask_update_changelog_on_bump(self) -> bool: ).unsafe_ask() return update_changelog_on_bump - def _exec_install_pre_commit_hook(self, hook_types: list[str]) -> None: - cmd_str = self._gen_pre_commit_cmd(hook_types) - c = cmd.run(cmd_str) - if c.return_code != 0: - err_msg = ( - f"Error running {cmd_str}." - "Outputs are attached below:\n" - f"stdout: {c.out}\n" - f"stderr: {c.err}" - ) - raise InitFailedError(err_msg) - - def _gen_pre_commit_cmd(self, hook_types: list[str]) -> str: - """Generate pre-commit command according to given hook types""" - if not hook_types: - raise ValueError("At least 1 hook type should be provided.") - return "pre-commit install " + " ".join( - f"--hook-type {ty}" for ty in hook_types - ) - def _get_config_data(self) -> dict[str, Any]: CZ_HOOK_CONFIG = { "repo": "https://github.com/commitizen-tools/commitizen", @@ -369,17 +369,3 @@ def _get_config_data(self) -> dict[str, Any]: else: repos.append(CZ_HOOK_CONFIG) return config_data - - def _install_pre_commit_hook(self, hook_types: list[str] | None = None) -> None: - config_data = self._get_config_data() - with smart_open( - self._PRE_COMMIT_CONFIG_PATH, "w", encoding=self.encoding - ) as config_file: - yaml.safe_dump(config_data, stream=config_file) - - if not self.project_info.is_pre_commit_installed: - raise InitFailedError("pre-commit is not installed in current environment.") - if hook_types is None: - hook_types = ["commit-msg", "pre-push"] - self._exec_install_pre_commit_hook(hook_types) - out.write("commitizen pre-commit hook is now installed in your '.git'\n") diff --git a/tests/commands/test_init_command.py b/tests/commands/test_init_command.py index ba4a15062..8c632a2b6 100644 --- a/tests/commands/test_init_command.py +++ b/tests/commands/test_init_command.py @@ -9,7 +9,7 @@ import yaml from pytest_mock import MockFixture -from commitizen import cli, commands +from commitizen import cli, cmd, commands from commitizen.__version__ import __version__ from commitizen.config.base_config import BaseConfig from commitizen.exceptions import InitFailedError, NoAnswersError @@ -117,12 +117,6 @@ def test_init_without_choosing_tag(config: BaseConfig, mocker: MockFixture, tmpd commands.Init(config)() -def test_executed_pre_commit_command(config: BaseConfig): - init = commands.Init(config) - expected_cmd = "pre-commit install --hook-type commit-msg --hook-type pre-push" - assert init._gen_pre_commit_cmd(["commit-msg", "pre-push"]) == expected_cmd - - @pytest.fixture(scope="function") def pre_commit_installed(mocker: MockFixture): # Assume the `pre-commit` is installed @@ -132,8 +126,8 @@ def pre_commit_installed(mocker: MockFixture): ) # And installation success (i.e. no exception raised) mocker.patch( - "commitizen.commands.init.Init._exec_install_pre_commit_hook", - return_value=None, + "commitizen.cmd.run", + return_value=cmd.Command("0.0.1", "", b"", b"", 0), ) @@ -244,23 +238,6 @@ def test_pre_commit_not_installed( with pytest.raises(InitFailedError): commands.Init(config)() - def test_pre_commit_exec_failed( - _, mocker: MockFixture, config: BaseConfig, default_choice: str, tmpdir - ): - # Assume `pre-commit` is installed - mocker.patch( - "commitizen.commands.init.ProjectInfo.is_pre_commit_installed", - return_value=True, - ) - # But pre-commit installation will fail - mocker.patch( - "commitizen.commands.init.Init._exec_install_pre_commit_hook", - side_effect=InitFailedError("Mock init failed error."), - ) - with tmpdir.as_cwd(): - with pytest.raises(InitFailedError): - commands.Init(config)() - class TestAskTagFormat: def test_confirm_v_tag_format(self, mocker: MockFixture, config: BaseConfig): From 289e83c3b5fceefc1cdea59481daf8181f2cf344 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Sun, 21 Sep 2025 00:43:07 +0800 Subject: [PATCH 06/33] test(changelog): remove unused timer fixture --- tests/commands/test_changelog_command.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tests/commands/test_changelog_command.py b/tests/commands/test_changelog_command.py index 1f3dabd76..f147c419b 100644 --- a/tests/commands/test_changelog_command.py +++ b/tests/commands/test_changelog_command.py @@ -884,7 +884,6 @@ def test_changelog_with_filename_as_empty_string( @pytest.mark.usefixtures("tmp_commitizen_project") -@pytest.mark.freeze_time("2022-02-13") def test_changelog_from_rev_first_version_from_arg( mocker: MockFixture, config_path, changelog_path, file_regression ): @@ -918,7 +917,6 @@ def test_changelog_from_rev_first_version_from_arg( @pytest.mark.usefixtures("tmp_commitizen_project") -@pytest.mark.freeze_time("2022-02-13") def test_changelog_from_rev_latest_version_from_arg( mocker: MockFixture, config_path, changelog_path, file_regression ): @@ -954,7 +952,6 @@ def test_changelog_from_rev_latest_version_from_arg( @pytest.mark.usefixtures("tmp_commitizen_project") -@pytest.mark.freeze_time("2022-02-13") @pytest.mark.parametrize( "rev_range,tag", ( @@ -986,7 +983,6 @@ def test_changelog_from_rev_range_not_found( @pytest.mark.usefixtures("tmp_commitizen_project") -@pytest.mark.freeze_time("2022-02-13") def test_changelog_multiple_matching_tags( mocker: MockFixture, config_path, changelog_path ): @@ -1016,7 +1012,6 @@ def test_changelog_multiple_matching_tags( @pytest.mark.usefixtures("tmp_commitizen_project") -@pytest.mark.freeze_time("2022-02-13") def test_changelog_from_rev_range_default_tag_format( mocker, config_path, changelog_path ): @@ -1047,7 +1042,6 @@ def test_changelog_from_rev_range_default_tag_format( @pytest.mark.usefixtures("tmp_commitizen_project") -@pytest.mark.freeze_time("2022-02-13") def test_changelog_from_rev_version_range_including_first_tag( mocker: MockFixture, config_path, changelog_path, file_regression ): @@ -1079,7 +1073,6 @@ def test_changelog_from_rev_version_range_including_first_tag( @pytest.mark.usefixtures("tmp_commitizen_project") -@pytest.mark.freeze_time("2022-02-13") def test_changelog_from_rev_version_range_from_arg( mocker: MockFixture, config_path, changelog_path, file_regression ): @@ -1119,7 +1112,6 @@ def test_changelog_from_rev_version_range_from_arg( @pytest.mark.usefixtures("tmp_commitizen_project") -@pytest.mark.freeze_time("2022-02-13") def test_changelog_from_rev_version_range_with_legacy_tags( mocker: MockFixture, config_path, changelog_path, file_regression ): @@ -1154,7 +1146,6 @@ def test_changelog_from_rev_version_range_with_legacy_tags( @pytest.mark.usefixtures("tmp_commitizen_project") -@pytest.mark.freeze_time("2022-02-13") def test_changelog_from_rev_version_with_big_range_from_arg( mocker: MockFixture, config_path, changelog_path, file_regression ): @@ -1214,7 +1205,6 @@ def test_changelog_from_rev_version_with_big_range_from_arg( @pytest.mark.usefixtures("tmp_commitizen_project") -@pytest.mark.freeze_time("2022-02-13") def test_changelog_from_rev_latest_version_dry_run( mocker: MockFixture, capsys, config_path, changelog_path, file_regression ): @@ -1267,7 +1257,6 @@ def test_invalid_subject_is_skipped(mocker: MockFixture, capsys): assert out == ("## Unreleased\n\n### Feat\n\n- a new world\n\n") -@pytest.mark.freeze_time("2022-02-13") @pytest.mark.usefixtures("tmp_commitizen_project") def test_changelog_with_customized_change_type_order( mocker, config_path, changelog_path, file_regression @@ -1325,7 +1314,6 @@ def test_empty_commit_list(mocker): @pytest.mark.usefixtures("tmp_commitizen_project") -@pytest.mark.freeze_time("2022-02-13") def test_changelog_prerelease_rev_with_use_scheme_semver( mocker: MockFixture, capsys, config_path, changelog_path, file_regression ): From e30f14e5fd3f916e8db9b0537131e8d787884d32 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Mon, 22 Sep 2025 17:20:51 +0800 Subject: [PATCH 07/33] refactor(cmd): unnest try except --- commitizen/cmd.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/commitizen/cmd.py b/commitizen/cmd.py index 3f1308723..c8d4f3301 100644 --- a/commitizen/cmd.py +++ b/commitizen/cmd.py @@ -22,13 +22,15 @@ def _try_decode(bytes_: bytes) -> str: try: return bytes_.decode("utf-8") except UnicodeDecodeError: - charset_match = from_bytes(bytes_).best() - if charset_match is None: - raise CharacterSetDecodeError() - try: - return bytes_.decode(charset_match.encoding) - except UnicodeDecodeError as e: - raise CharacterSetDecodeError() from e + pass + + charset_match = from_bytes(bytes_).best() + if charset_match is None: + raise CharacterSetDecodeError() + try: + return bytes_.decode(charset_match.encoding) + except UnicodeDecodeError as e: + raise CharacterSetDecodeError() from e def run(cmd: str, env: Mapping[str, str] | None = None) -> Command: From e7d1084ca8efa25a7aed594cb8f7eff9064fcfc3 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Sun, 21 Sep 2025 11:40:37 +0800 Subject: [PATCH 08/33] refactor(ConventionalCommitsCz): rewrite message method to make the pattern more clear --- .../cz/conventional_commits/conventional_commits.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/commitizen/cz/conventional_commits/conventional_commits.py b/commitizen/cz/conventional_commits/conventional_commits.py index 689342347..c827cebb7 100644 --- a/commitizen/cz/conventional_commits/conventional_commits.py +++ b/commitizen/cz/conventional_commits/conventional_commits.py @@ -155,15 +155,16 @@ def message(self, answers: ConventionalCommitsAnswers) -> str: # type: ignore[o is_breaking_change = answers["is_breaking_change"] if scope: - scope = f"({scope})" - if body: - body = f"\n\n{body}" + # example: "fix(users): email pattern corrected" + first_line = f"{prefix}({scope}): {subject}" + else: + # example: "fix: email pattern corrected" + first_line = f"{prefix}: {subject}" + if is_breaking_change: footer = f"BREAKING CHANGE: {footer}" - if footer: - footer = f"\n\n{footer}" - return f"{prefix}{scope}: {subject}{body}{footer}" + return "\n\n".join(s for s in (first_line, body, footer) if s) def example(self) -> str: return ( From 8cacb25e77c7147ea5bc311f3aad78ffff061701 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Sun, 21 Sep 2025 00:24:12 +0800 Subject: [PATCH 09/33] refactor(Bump): remove use of getattr --- commitizen/commands/bump.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index 07338afb8..fc9649c6c 100644 --- a/commitizen/commands/bump.py +++ b/commitizen/commands/bump.py @@ -216,8 +216,8 @@ def __call__(self) -> None: rules = TagRules.from_settings(cast(Settings, self.bump_settings)) current_tag = rules.find_tag_for(git.get_tags(), current_version) - current_tag_version = getattr( - current_tag, "name", rules.normalize_tag(current_version) + current_tag_version = ( + current_tag.name if current_tag else rules.normalize_tag(current_version) ) is_initial = self._is_initial_tag(current_tag, self.arguments["yes"]) From 8684796f3acc6ead74944320132b14947a352140 Mon Sep 17 00:00:00 2001 From: Santiago Fraire Willemoes Date: Thu, 30 Oct 2025 07:47:06 +0000 Subject: [PATCH 10/33] docs: add contributing for nix users --- docs/contributing.md | 16 +++++++++++++++- docs/contributing_tldr.md | 13 ++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index 8389e9370..4a068cf64 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -38,7 +38,7 @@ If you're a first-time contributor, please check out issues labeled [good first ```bash git remote add upstream https://github.com/commitizen-tools/commitizen.git ``` -4. Set up the development environment: +4. Set up the development environment (nix users go to [nix section](#nix)): ```bash poetry install ``` @@ -70,6 +70,7 @@ If you're a first-time contributor, please check out issues labeled [good first 5. **Documentation** - Update `docs/README.md` if needed - For CLI help screenshots: `poetry doc:screenshots` + - Prefer [Google style documentation](https://github.com/google/styleguide/blob/gh-pages/pyguide.md#38-comments-and-docstrings), which works well with editors like VSCode and PyCharm - **DO NOT** update `CHANGELOG.md` (automatically generated) - **DO NOT** update version numbers (automatically handled) 6. **Pull Request** @@ -153,3 +154,16 @@ flowchart TD --modification-received--> review ``` + +## Nix + +If you have installed poetry globally, the project won't work because it requries `poethepoet` for command management. + +You'll have to install poetry locally. + +```sh +python -m venv .venv +. .venv/bin/activate +pip install -U pip && pip install poetry +poetry install +``` diff --git a/docs/contributing_tldr.md b/docs/contributing_tldr.md index f5483cddc..9eecc3efa 100644 --- a/docs/contributing_tldr.md +++ b/docs/contributing_tldr.md @@ -12,7 +12,7 @@ Please check the [pyproject.toml](https://github.com/commitizen-tools/commitizen ### Code Changes ```bash -# Ensure you have the correct dependencies +# Ensure you have the correct dependencies, for nix user's see below poetry install # Make ruff happy @@ -35,3 +35,14 @@ pytest -n auto # Build the documentation locally and check for broken links poetry doc ``` + +### Nix Users + +If you are using Nix, you can install poetry locally by running: + +```sh +python -m venv .venv +. .venv/bin/activate +pip install -U pip && pip install poetry +poetry install +``` From d734348ec9065fa474b76e68620b8916577d7c9b Mon Sep 17 00:00:00 2001 From: Santiago Fraire Willemoes Date: Thu, 30 Oct 2025 08:28:48 +0000 Subject: [PATCH 11/33] feat(version): add the ability to just print major or minor version --- commitizen/cli.py | 12 +++ commitizen/commands/version.py | 16 +++- commitizen/version_schemes.py | 4 +- docs/contributing.md | 2 +- docs/images/cli_help/cz_version___help.svg | 82 ++++++++++--------- tests/commands/test_version_command.py | 37 +++++++++ ...shows_description_when_use_help_option.txt | 4 +- 7 files changed, 116 insertions(+), 41 deletions(-) diff --git a/commitizen/cli.py b/commitizen/cli.py index ed4305ea1..e9689d75f 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -543,6 +543,18 @@ def __call__( "action": "store_true", "exclusive_group": "group1", }, + { + "name": ["--major"], + "help": "get just the major version", + "action": "store_true", + "exclusive_group": "group2", + }, + { + "name": ["--minor"], + "help": "get just the minor version", + "action": "store_true", + "exclusive_group": "group2", + }, ], }, ], diff --git a/commitizen/commands/version.py b/commitizen/commands/version.py index 35e9aa6cd..04fa664f2 100644 --- a/commitizen/commands/version.py +++ b/commitizen/commands/version.py @@ -5,14 +5,17 @@ from commitizen import out from commitizen.__version__ import __version__ from commitizen.config import BaseConfig -from commitizen.exceptions import NoVersionSpecifiedError +from commitizen.exceptions import NoVersionSpecifiedError, VersionSchemeUnknown from commitizen.providers import get_provider +from commitizen.version_schemes import get_version_scheme class VersionArgs(TypedDict, total=False): report: bool project: bool verbose: bool + major: bool + minor: bool class Version: @@ -41,6 +44,17 @@ def __call__(self) -> None: out.error("No project information in this project.") return + try: + version_scheme = get_version_scheme(self.config.settings) + except VersionSchemeUnknown: + out.error("Unknown version scheme.") + _version = version_scheme(version) + + if self.parameter.get("major"): + version = f"{_version.major}" + elif self.parameter.get("minor"): + version = f"{_version.minor}" + out.write(f"Project Version: {version}" if verbose else version) return diff --git a/commitizen/version_schemes.py b/commitizen/version_schemes.py index e9f99c551..0696d85aa 100644 --- a/commitizen/version_schemes.py +++ b/commitizen/version_schemes.py @@ -410,7 +410,9 @@ def _get_prerelease(self) -> str: def get_version_scheme(settings: Settings, name: str | None = None) -> VersionScheme: """ Get the version scheme as defined in the configuration - or from an overridden `name` + or from an overridden `name`. + + :raises VersionSchemeUnknown: if the version scheme is not found. """ diff --git a/docs/contributing.md b/docs/contributing.md index 4a068cf64..16db93062 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -157,7 +157,7 @@ flowchart TD ## Nix -If you have installed poetry globally, the project won't work because it requries `poethepoet` for command management. +If you have installed poetry globally, the project won't work because it requires `poethepoet` for command management. You'll have to install poetry locally. diff --git a/docs/images/cli_help/cz_version___help.svg b/docs/images/cli_help/cz_version___help.svg index c7777db4d..7ddec177d 100644 --- a/docs/images/cli_help/cz_version___help.svg +++ b/docs/images/cli_help/cz_version___help.svg @@ -1,4 +1,4 @@ - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + - + - + - - $ cz version --help -usage: cz version [-h][-r | -p | -c | -v] - -get the version of the installed commitizen or the current project (default: -installed commitizen) - -options: -  -h, --help        show this help message and exit -  -r, --report      get system information for reporting bugs -  -p, --project     get the version of the current project -  -c, --commitizen  get the version of the installed commitizen -  -v, --verbose     get the version of both the installed commitizen and the -                    current project - + + $ cz version --help +usage: cz version [-h][-r | -p | -c | -v][--major | --minor] + +get the version of the installed commitizen or the current project (default: +installed commitizen) + +options: +  -h, --help        show this help message and exit +  -r, --report      get system information for reporting bugs +  -p, --project     get the version of the current project +  -c, --commitizen  get the version of the installed commitizen +  -v, --verbose     get the version of both the installed commitizen and the +                    current project +  --major           get just the major version +  --minor           get just the minor version + diff --git a/tests/commands/test_version_command.py b/tests/commands/test_version_command.py index 3dcbed168..cd2e7f77e 100644 --- a/tests/commands/test_version_command.py +++ b/tests/commands/test_version_command.py @@ -119,3 +119,40 @@ def test_version_command_shows_description_when_use_help_option( out, _ = capsys.readouterr() file_regression.check(out, extension=".txt") + + +@pytest.mark.parametrize( + "version, expected_version", (("1.0.0", "1\n"), ("2.1.3", "2\n"), ("0.0.1", "0\n")) +) +def test_version_just_major(config, capsys, version: str, expected_version: str): + config.settings["version"] = version + commands.Version( + config, + { + "report": False, + "project": True, + "verbose": False, + "major": True, + }, + )() + captured = capsys.readouterr() + assert expected_version == captured.out + + +@pytest.mark.parametrize( + "version, expected_version", + (("1.0.0", "0\n"), ("2.1.3", "1\n"), ("0.0.1", "0\n"), ("0.1.0", "1\n")), +) +def test_version_just_minor(config, capsys, version: str, expected_version: str): + config.settings["version"] = version + commands.Version( + config, + { + "report": False, + "project": True, + "verbose": False, + "minor": True, + }, + )() + captured = capsys.readouterr() + assert expected_version == captured.out diff --git a/tests/commands/test_version_command/test_version_command_shows_description_when_use_help_option.txt b/tests/commands/test_version_command/test_version_command_shows_description_when_use_help_option.txt index c461b10bc..b1ed94124 100644 --- a/tests/commands/test_version_command/test_version_command_shows_description_when_use_help_option.txt +++ b/tests/commands/test_version_command/test_version_command_shows_description_when_use_help_option.txt @@ -1,4 +1,4 @@ -usage: cz version [-h] [-r | -p | -c | -v] +usage: cz version [-h] [-r | -p | -c | -v] [--major | --minor] get the version of the installed commitizen or the current project (default: installed commitizen) @@ -10,3 +10,5 @@ options: -c, --commitizen get the version of the installed commitizen -v, --verbose get the version of both the installed commitizen and the current project + --major get just the major version + --minor get just the minor version From 4f596d80ef3a9d0ac9c8748f7b293eed83ba09ab Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Sat, 20 Sep 2025 01:17:54 +0800 Subject: [PATCH 12/33] refactor: remove unnecessary class member tag_format --- commitizen/changelog_formats/base.py | 3 +-- commitizen/commands/changelog.py | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/commitizen/changelog_formats/base.py b/commitizen/changelog_formats/base.py index cb5d385bf..0a76ad68f 100644 --- a/commitizen/changelog_formats/base.py +++ b/commitizen/changelog_formats/base.py @@ -25,10 +25,9 @@ def __init__(self, config: BaseConfig) -> None: # See: https://bugs.python.org/issue44807 self.config = config self.encoding = self.config.settings["encoding"] - self.tag_format = self.config.settings["tag_format"] self.tag_rules = TagRules( scheme=get_version_scheme(self.config.settings), - tag_format=self.tag_format, + tag_format=self.config.settings["tag_format"], legacy_tag_formats=self.config.settings["legacy_tag_formats"], ignored_tag_formats=self.config.settings["ignored_tag_formats"], ) diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py index 0fffd8ed7..c739419b8 100644 --- a/commitizen/commands/changelog.py +++ b/commitizen/commands/changelog.py @@ -104,12 +104,10 @@ def __init__(self, config: BaseConfig, arguments: ChangelogArgs) -> None: or defaults.CHANGE_TYPE_ORDER, ) self.rev_range = arguments.get("rev_range") - self.tag_format = ( - arguments.get("tag_format") or self.config.settings["tag_format"] - ) self.tag_rules = TagRules( scheme=self.scheme, - tag_format=self.tag_format, + tag_format=arguments.get("tag_format") + or self.config.settings["tag_format"], legacy_tag_formats=self.config.settings["legacy_tag_formats"], ignored_tag_formats=self.config.settings["ignored_tag_formats"], merge_prereleases=arguments.get("merge_prerelease") From e482b405067b47a2c66b8329148d67ff10305078 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Mon, 29 Sep 2025 11:42:31 +0800 Subject: [PATCH 13/33] refactor(utils): make get_backup_file_path to return a path for semantic correctness --- commitizen/commands/commit.py | 28 +++++++++++++-------------- commitizen/cz/utils.py | 6 +++--- hooks/post-commit.py | 7 +++---- hooks/prepare-commit-msg.py | 9 ++++----- tests/commands/test_commit_command.py | 8 ++++---- 5 files changed, 28 insertions(+), 30 deletions(-) diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 19bb72fb0..0a6349303 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -51,15 +51,15 @@ def __init__(self, config: BaseConfig, arguments: CommitArgs) -> None: self.encoding = config.settings["encoding"] self.cz = factory.committer_factory(self.config) self.arguments = arguments - self.temp_file: str = get_backup_file_path() + self.backup_file_path = get_backup_file_path() def _read_backup_message(self) -> str | None: # Check the commit backup file exists - if not os.path.isfile(self.temp_file): + if not self.backup_file_path.is_file(): return None # Read commit message from backup - with open(self.temp_file, encoding=self.encoding) as f: + with open(self.backup_file_path, encoding=self.encoding) as f: return f.read().strip() def _prompt_commit_questions(self) -> str: @@ -108,10 +108,10 @@ def manual_edit(self, message: str) -> str: def _get_message(self) -> str: if self.arguments.get("retry"): - m = self._read_backup_message() - if m is None: + commit_message = self._read_backup_message() + if commit_message is None: raise NoCommitBackupError() - return m + return commit_message if self.config.settings.get("retry_after_failure") and not self.arguments.get( "no_retry" @@ -139,15 +139,15 @@ def __call__(self) -> None: if write_message_to_file is not None and write_message_to_file.is_dir(): raise NotAllowed(f"{write_message_to_file} is a directory") - m = self._get_message() + commit_message = self._get_message() if self.arguments.get("edit"): - m = self.manual_edit(m) + commit_message = self.manual_edit(commit_message) - out.info(f"\n{m}\n") + out.info(f"\n{commit_message}\n") if write_message_to_file: with smart_open(write_message_to_file, "w", encoding=self.encoding) as file: - file.write(m) + file.write(commit_message) if dry_run: raise DryRunExit() @@ -155,13 +155,13 @@ def __call__(self) -> None: if self.config.settings["always_signoff"] or signoff: extra_args = f"{extra_args} -s".strip() - c = git.commit(m, args=extra_args) + c = git.commit(commit_message, args=extra_args) if c.return_code != 0: out.error(c.err) # Create commit backup - with smart_open(self.temp_file, "w", encoding=self.encoding) as f: - f.write(m) + with smart_open(self.backup_file_path, "w", encoding=self.encoding) as f: + f.write(commit_message) raise CommitError() @@ -170,7 +170,7 @@ def __call__(self) -> None: return with contextlib.suppress(FileNotFoundError): - os.remove(self.temp_file) + self.backup_file_path.unlink() out.write(c.err) out.write(c.out) out.success("Commit successful!") diff --git a/commitizen/cz/utils.py b/commitizen/cz/utils.py index a6f687226..ba5eace44 100644 --- a/commitizen/cz/utils.py +++ b/commitizen/cz/utils.py @@ -1,6 +1,7 @@ import os import re import tempfile +from pathlib import Path from commitizen import git from commitizen.cz import exceptions @@ -22,10 +23,9 @@ def strip_local_version(version: str) -> str: return _RE_LOCAL_VERSION.sub("", version) -def get_backup_file_path() -> str: +def get_backup_file_path() -> Path: project_root = git.find_git_project_root() project = project_root.as_posix().replace("/", "%") if project_root else "" user = os.environ.get("USER", "") - - return os.path.join(tempfile.gettempdir(), f"cz.commit%{user}%{project}.backup") + return Path(tempfile.gettempdir(), f"cz.commit%{user}%{project}.backup") diff --git a/hooks/post-commit.py b/hooks/post-commit.py index c7dea825b..d242b6b35 100755 --- a/hooks/post-commit.py +++ b/hooks/post-commit.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -from pathlib import Path try: from commitizen.cz.utils import get_backup_file_path @@ -9,11 +8,11 @@ def post_commit() -> None: - backup_file = Path(get_backup_file_path()) + backup_file_path = get_backup_file_path() # remove backup file if it exists - if backup_file.is_file(): - backup_file.unlink() + if backup_file_path.is_file(): + backup_file_path.unlink() if __name__ == "__main__": diff --git a/hooks/prepare-commit-msg.py b/hooks/prepare-commit-msg.py index e666fa673..a100f13af 100755 --- a/hooks/prepare-commit-msg.py +++ b/hooks/prepare-commit-msg.py @@ -2,7 +2,6 @@ import shutil import subprocess import sys -from pathlib import Path from subprocess import CalledProcessError try: @@ -25,12 +24,12 @@ def prepare_commit_msg(commit_msg_file: str) -> int: capture_output=True, ).returncode if exit_code != 0: - backup_file = Path(get_backup_file_path()) - if backup_file.is_file(): + backup_file_path = get_backup_file_path() + if backup_file_path.is_file(): # confirm if commit message from backup file should be reused answer = input("retry with previous message? [y/N]: ") if answer.lower() == "y": - shutil.copyfile(backup_file, commit_msg_file) + shutil.copyfile(backup_file_path, commit_msg_file) return 0 # use commitizen to generate the commit message @@ -50,7 +49,7 @@ def prepare_commit_msg(commit_msg_file: str) -> int: return error.returncode # write message to backup file - shutil.copyfile(commit_msg_file, backup_file) + shutil.copyfile(commit_msg_file, backup_file_path) return 0 diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index 930e1a7a9..8d0181f3e 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -73,7 +73,7 @@ def test_commit_backup_on_failure(config, mocker: MockFixture): with pytest.raises(CommitError): commit_cmd = commands.Commit(config, {}) - temp_file = commit_cmd.temp_file + temp_file = commit_cmd.backup_file_path commit_cmd() prompt_mock.assert_called_once() @@ -101,7 +101,7 @@ def test_commit_retry_works(config, mocker: MockFixture): success_mock = mocker.patch("commitizen.out.success") commit_cmd = commands.Commit(config, {"retry": True}) - temp_file = commit_cmd.temp_file + temp_file = commit_cmd.backup_file_path commit_cmd() commit_mock.assert_called_with("backup commit", args="") @@ -144,7 +144,7 @@ def test_commit_retry_after_failure_works(config, mocker: MockFixture): config.settings["retry_after_failure"] = True commit_cmd = commands.Commit(config, {}) - temp_file = commit_cmd.temp_file + temp_file = commit_cmd.backup_file_path commit_cmd() commit_mock.assert_called_with("backup commit", args="") @@ -171,7 +171,7 @@ def test_commit_retry_after_failure_with_no_retry_works(config, mocker: MockFixt config.settings["retry_after_failure"] = True commit_cmd = commands.Commit(config, {"no_retry": True}) - temp_file = commit_cmd.temp_file + temp_file = commit_cmd.backup_file_path commit_cmd() commit_mock.assert_called_with("feat: user created\n\ncloses #21", args="") From 1c60d27660a822e43cc5d862a8af3dfe2bf32d39 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Sat, 20 Sep 2025 01:02:02 +0800 Subject: [PATCH 14/33] docs(Check): add missing raise exception in __call__ --- commitizen/commands/check.py | 1 + 1 file changed, 1 insertion(+) diff --git a/commitizen/commands/check.py b/commitizen/commands/check.py index e6ebc928e..b79e6609e 100644 --- a/commitizen/commands/check.py +++ b/commitizen/commands/check.py @@ -79,6 +79,7 @@ def __call__(self) -> None: Raises: InvalidCommitMessageError: if the commit provided not follows the conventional pattern + NoCommitsFoundError: if no commit is found with the given range """ commits = self._get_commits() if not commits: From 18ac68b13ebf8ba2091d9841888ed3c5e592393b Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Sat, 13 Sep 2025 17:25:15 +0800 Subject: [PATCH 15/33] test(SemVer): refine tests and remove unnecessary tests which test the behavior of library, improve readability with NamedTuple --- tests/test_version_scheme_pep440.py | 1629 +++++++++++++++++++++----- tests/test_version_scheme_semver.py | 1084 +++++++++++++---- tests/test_version_scheme_semver2.py | 824 ++++++++++--- tests/utils.py | 10 + 4 files changed, 2831 insertions(+), 716 deletions(-) diff --git a/tests/test_version_scheme_pep440.py b/tests/test_version_scheme_pep440.py index a983dad14..0ce4f8154 100644 --- a/tests/test_version_scheme_pep440.py +++ b/tests/test_version_scheme_pep440.py @@ -1,258 +1,1356 @@ -import itertools -import random - import pytest from commitizen.version_schemes import Pep440, VersionProtocol - -simple_flow = [ - (("0.1.0", "PATCH", None, 0, None), "0.1.1"), - (("0.1.0", "PATCH", None, 0, 1), "0.1.1.dev1"), - (("0.1.1", "MINOR", None, 0, None), "0.2.0"), - (("0.2.0", "MINOR", None, 0, None), "0.3.0"), - (("0.2.0", "MINOR", None, 0, 1), "0.3.0.dev1"), - (("0.3.0", "PATCH", None, 0, None), "0.3.1"), - (("0.3.0", "PATCH", "alpha", 0, None), "0.3.1a0"), - (("0.3.1a0", None, "alpha", 0, None), "0.3.1a1"), - (("0.3.0", "PATCH", "alpha", 1, None), "0.3.1a1"), - (("0.3.1a0", None, "alpha", 1, None), "0.3.1a1"), - (("0.3.1a0", None, None, 0, None), "0.3.1"), - (("0.3.1", "PATCH", None, 0, None), "0.3.2"), - (("0.4.2", "MAJOR", "alpha", 0, None), "1.0.0a0"), - (("1.0.0a0", None, "alpha", 0, None), "1.0.0a1"), - (("1.0.0a1", None, "alpha", 0, None), "1.0.0a2"), - (("1.0.0a1", None, "alpha", 0, 1), "1.0.0a2.dev1"), - (("1.0.0a2.dev0", None, "alpha", 0, 1), "1.0.0a3.dev1"), - (("1.0.0a2.dev0", None, "alpha", 0, 0), "1.0.0a3.dev0"), - (("1.0.0a1", None, "beta", 0, None), "1.0.0b0"), - (("1.0.0b0", None, "beta", 0, None), "1.0.0b1"), - (("1.0.0b1", None, "rc", 0, None), "1.0.0rc0"), - (("1.0.0rc0", None, "rc", 0, None), "1.0.0rc1"), - (("1.0.0rc0", None, "rc", 0, 1), "1.0.0rc1.dev1"), - (("1.0.0rc0", "PATCH", None, 0, None), "1.0.0"), - (("1.0.0a3.dev0", None, "beta", 0, None), "1.0.0b0"), - (("1.0.0", "PATCH", None, 0, None), "1.0.1"), - (("1.0.1", "PATCH", None, 0, None), "1.0.2"), - (("1.0.2", "MINOR", None, 0, None), "1.1.0"), - (("1.1.0", "MINOR", None, 0, None), "1.2.0"), - (("1.2.0", "PATCH", None, 0, None), "1.2.1"), - (("1.2.1", "MAJOR", None, 0, None), "2.0.0"), -] - -local_versions = [ - (("4.5.0+0.1.0", "PATCH", None, 0, None), "4.5.0+0.1.1"), - (("4.5.0+0.1.1", "MINOR", None, 0, None), "4.5.0+0.2.0"), - (("4.5.0+0.2.0", "MAJOR", None, 0, None), "4.5.0+1.0.0"), -] - -# never bump backwards on pre-releases -linear_prerelease_cases = [ - (("0.1.1b1", None, "alpha", 0, None), "0.1.1b2"), - (("0.1.1rc0", None, "alpha", 0, None), "0.1.1rc1"), - (("0.1.1rc0", None, "beta", 0, None), "0.1.1rc1"), -] - -weird_cases = [ - (("1.1", "PATCH", None, 0, None), "1.1.1"), - (("1", "MINOR", None, 0, None), "1.1.0"), - (("1", "MAJOR", None, 0, None), "2.0.0"), - (("1a0", None, "alpha", 0, None), "1.0.0a1"), - (("1a0", None, "alpha", 1, None), "1.0.0a1"), - (("1", None, "beta", 0, None), "1.0.0b0"), - (("1", None, "beta", 1, None), "1.0.0b1"), - (("1beta", None, "beta", 0, None), "1.0.0b1"), - (("1.0.0alpha1", None, "alpha", 0, None), "1.0.0a2"), - (("1", None, "rc", 0, None), "1.0.0rc0"), - (("1.0.0rc1+e20d7b57f3eb", "PATCH", None, 0, None), "1.0.0"), -] - -# test driven development -tdd_cases = [ - (("0.1.1", "PATCH", None, 0, None), "0.1.2"), - (("0.1.1", "MINOR", None, 0, None), "0.2.0"), - (("2.1.1", "MAJOR", None, 0, None), "3.0.0"), - (("0.9.0", "PATCH", "alpha", 0, None), "0.9.1a0"), - (("0.9.0", "MINOR", "alpha", 0, None), "0.10.0a0"), - (("0.9.0", "MAJOR", "alpha", 0, None), "1.0.0a0"), - (("0.9.0", "MAJOR", "alpha", 1, None), "1.0.0a1"), - (("1.0.0a2", None, "beta", 0, None), "1.0.0b0"), - (("1.0.0a2", None, "beta", 1, None), "1.0.0b1"), - (("1.0.0beta1", None, "rc", 0, None), "1.0.0rc0"), - (("1.0.0rc1", None, "rc", 0, None), "1.0.0rc2"), -] - -# additional pre-release tests run through various release scenarios -prerelease_cases = [ - # - (("3.3.3", "PATCH", "alpha", 0, None), "3.3.4a0"), - (("3.3.4a0", "PATCH", "alpha", 0, None), "3.3.4a1"), - (("3.3.4a1", "MINOR", "alpha", 0, None), "3.4.0a0"), - (("3.4.0a0", "PATCH", "alpha", 0, None), "3.4.0a1"), - (("3.4.0a1", "MINOR", "alpha", 0, None), "3.4.0a2"), - (("3.4.0a2", "MAJOR", "alpha", 0, None), "4.0.0a0"), - (("4.0.0a0", "PATCH", "alpha", 0, None), "4.0.0a1"), - (("4.0.0a1", "MINOR", "alpha", 0, None), "4.0.0a2"), - (("4.0.0a2", "MAJOR", "alpha", 0, None), "4.0.0a3"), - # - (("1.0.0", "PATCH", "alpha", 0, None), "1.0.1a0"), - (("1.0.1a0", "PATCH", "alpha", 0, None), "1.0.1a1"), - (("1.0.1a1", "MINOR", "alpha", 0, None), "1.1.0a0"), - (("1.1.0a0", "PATCH", "alpha", 0, None), "1.1.0a1"), - (("1.1.0a1", "MINOR", "alpha", 0, None), "1.1.0a2"), - (("1.1.0a2", "MAJOR", "alpha", 0, None), "2.0.0a0"), - # - (("1.0.0", "MINOR", "alpha", 0, None), "1.1.0a0"), - (("1.1.0a0", "PATCH", "alpha", 0, None), "1.1.0a1"), - (("1.1.0a1", "MINOR", "alpha", 0, None), "1.1.0a2"), - (("1.1.0a2", "PATCH", "alpha", 0, None), "1.1.0a3"), - (("1.1.0a3", "MAJOR", "alpha", 0, None), "2.0.0a0"), - # - (("1.0.0", "MAJOR", "alpha", 0, None), "2.0.0a0"), - (("2.0.0a0", "MINOR", "alpha", 0, None), "2.0.0a1"), - (("2.0.0a1", "PATCH", "alpha", 0, None), "2.0.0a2"), - (("2.0.0a2", "MAJOR", "alpha", 0, None), "2.0.0a3"), - (("2.0.0a3", "MINOR", "alpha", 0, None), "2.0.0a4"), - (("2.0.0a4", "PATCH", "alpha", 0, None), "2.0.0a5"), - (("2.0.0a5", "MAJOR", "alpha", 0, None), "2.0.0a6"), - # - (("2.0.0b0", "MINOR", "alpha", 0, None), "2.0.0b1"), - (("2.0.0b0", "PATCH", "alpha", 0, None), "2.0.0b1"), - # - (("1.0.1a0", "PATCH", None, 0, None), "1.0.1"), - (("1.0.1a0", "MINOR", None, 0, None), "1.1.0"), - (("1.0.1a0", "MAJOR", None, 0, None), "2.0.0"), - # - (("1.1.0a0", "PATCH", None, 0, None), "1.1.0"), - (("1.1.0a0", "MINOR", None, 0, None), "1.1.0"), - (("1.1.0a0", "MAJOR", None, 0, None), "2.0.0"), - # - (("2.0.0a0", "MINOR", None, 0, None), "2.0.0"), - (("2.0.0a0", "MAJOR", None, 0, None), "2.0.0"), - (("2.0.0a0", "PATCH", None, 0, None), "2.0.0"), - # - (("3.0.0a1", None, None, 0, None), "3.0.0"), - (("3.0.0b1", None, None, 0, None), "3.0.0"), - (("3.0.0rc1", None, None, 0, None), "3.0.0"), - # - (("3.1.4", None, "alpha", 0, None), "3.1.4a0"), - (("3.1.4", None, "beta", 0, None), "3.1.4b0"), - (("3.1.4", None, "rc", 0, None), "3.1.4rc0"), - # - (("3.1.4", None, "alpha", 0, None), "3.1.4a0"), - (("3.1.4a0", "PATCH", "alpha", 0, None), "3.1.4a1"), # UNEXPECTED! - (("3.1.4a0", "MINOR", "alpha", 0, None), "3.2.0a0"), - (("3.1.4a0", "MAJOR", "alpha", 0, None), "4.0.0a0"), -] - -exact_cases = [ - (("1.0.0", "PATCH", None, 0, None), "1.0.1"), - (("1.0.0", "MINOR", None, 0, None), "1.1.0"), - # with exact_increment=False: "1.0.0b0" - (("1.0.0a1", "PATCH", "beta", 0, None), "1.0.1b0"), - # with exact_increment=False: "1.0.0b1" - (("1.0.0b0", "PATCH", "beta", 0, None), "1.0.1b0"), - # with exact_increment=False: "1.0.0rc0" - (("1.0.0b1", "PATCH", "rc", 0, None), "1.0.1rc0"), - # with exact_increment=False: "1.0.0-rc1" - (("1.0.0rc0", "PATCH", "rc", 0, None), "1.0.1rc0"), - # with exact_increment=False: "1.0.0rc1-dev1" - (("1.0.0rc0", "PATCH", "rc", 0, 1), "1.0.1rc0.dev1"), - # with exact_increment=False: "1.0.0b0" - (("1.0.0a1", "MINOR", "beta", 0, None), "1.1.0b0"), - # with exact_increment=False: "1.0.0b1" - (("1.0.0b0", "MINOR", "beta", 0, None), "1.1.0b0"), - # with exact_increment=False: "1.0.0b1" - (("1.0.0b0", "MINOR", "alpha", 0, None), "1.1.0a0"), - # with exact_increment=False: "1.0.0rc0" - (("1.0.0b1", "MINOR", "rc", 0, None), "1.1.0rc0"), - # with exact_increment=False: "1.0.0rc1" - (("1.0.0rc0", "MINOR", "rc", 0, None), "1.1.0rc0"), - # with exact_increment=False: "1.0.0rc1-dev1" - (("1.0.0rc0", "MINOR", "rc", 0, 1), "1.1.0rc0.dev1"), - # with exact_increment=False: "2.0.0" - (("2.0.0b0", "MAJOR", None, 0, None), "3.0.0"), - # with exact_increment=False: "2.0.0" - (("2.0.0b0", "MINOR", None, 0, None), "2.1.0"), - # with exact_increment=False: "2.0.0" - (("2.0.0b0", "PATCH", None, 0, None), "2.0.1"), - # same with exact_increment=False - (("2.0.0b0", "MAJOR", "alpha", 0, None), "3.0.0a0"), - # with exact_increment=False: "2.0.0b1" - (("2.0.0b0", "MINOR", "alpha", 0, None), "2.1.0a0"), - # with exact_increment=False: "2.0.0b1" - (("2.0.0b0", "PATCH", "alpha", 0, None), "2.0.1a0"), -] +from tests.utils import VersionSchemeTestArgs @pytest.mark.parametrize( - "test_input,expected", - itertools.chain( - tdd_cases, - weird_cases, - simple_flow, - linear_prerelease_cases, - prerelease_cases, - ), + "version_args, expected_version", + [ + ( + VersionSchemeTestArgs( + current_version="0.1.1", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.1.2", + ), + ( + VersionSchemeTestArgs( + current_version="0.1.1", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.2.0", + ), + ( + VersionSchemeTestArgs( + current_version="2.1.1", + increment="MAJOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "3.0.0", + ), + ( + VersionSchemeTestArgs( + current_version="0.9.0", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "0.9.1a0", + ), + ( + VersionSchemeTestArgs( + current_version="0.9.0", + increment="MINOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "0.10.0a0", + ), + ( + VersionSchemeTestArgs( + current_version="0.9.0", + increment="MAJOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0a0", + ), + ( + VersionSchemeTestArgs( + current_version="0.9.0", + increment="MAJOR", + prerelease="alpha", + prerelease_offset=1, + devrelease=None, + ), + "1.0.0a1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0a2", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0b0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0a2", + increment=None, + prerelease="beta", + prerelease_offset=1, + devrelease=None, + ), + "1.0.0b1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0beta1", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0rc0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0rc1", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0rc2", + ), + # weird cases + ( + VersionSchemeTestArgs( + current_version="1.1", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.1.1", + ), + ( + VersionSchemeTestArgs( + current_version="1", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.1.0", + ), + ( + VersionSchemeTestArgs( + current_version="1", + increment="MAJOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "2.0.0", + ), + ( + VersionSchemeTestArgs( + current_version="1a0", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0a1", + ), + ( + VersionSchemeTestArgs( + current_version="1a0", + increment=None, + prerelease="alpha", + prerelease_offset=1, + devrelease=None, + ), + "1.0.0a1", + ), + ( + VersionSchemeTestArgs( + current_version="1", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0b0", + ), + ( + VersionSchemeTestArgs( + current_version="1", + increment=None, + prerelease="beta", + prerelease_offset=1, + devrelease=None, + ), + "1.0.0b1", + ), + ( + VersionSchemeTestArgs( + current_version="1beta", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0b1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0alpha1", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0a2", + ), + ( + VersionSchemeTestArgs( + current_version="1", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0rc0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0rc1+e20d7b57f3eb", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.0.0", + ), + # simple flow + ( + VersionSchemeTestArgs( + current_version="0.1.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.1.1", + ), + ( + VersionSchemeTestArgs( + current_version="0.1.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=1, + ), + "0.1.1.dev1", + ), + ( + VersionSchemeTestArgs( + current_version="0.1.1", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.2.0", + ), + ( + VersionSchemeTestArgs( + current_version="0.2.0", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.3.0", + ), + ( + VersionSchemeTestArgs( + current_version="0.2.0", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=1, + ), + "0.3.0.dev1", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.3.1", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.0", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "0.3.1a0", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.1a0", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "0.3.1a1", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.0", + increment="PATCH", + prerelease="alpha", + prerelease_offset=1, + devrelease=None, + ), + "0.3.1a1", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.1a0", + increment=None, + prerelease="alpha", + prerelease_offset=1, + devrelease=None, + ), + "0.3.1a1", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.1a0", + increment=None, + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.3.1", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.1", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.3.2", + ), + ( + VersionSchemeTestArgs( + current_version="0.4.2", + increment="MAJOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0a0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0a0", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0a1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0a1", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0a2", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0a1", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=1, + ), + "1.0.0a2.dev1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0a2.dev0", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=1, + ), + "1.0.0a3.dev1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0a2.dev0", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=0, + ), + "1.0.0a3.dev0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0a1", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0b0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0b0", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0b1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0b1", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0rc0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0rc0", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0rc1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0rc0", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=1, + ), + "1.0.0rc1.dev1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0rc0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.0.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0a3.dev0", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0b0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.0.1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.1", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.0.2", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.2", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.1.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.1.0", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.2.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.2.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.2.1", + ), + ( + VersionSchemeTestArgs( + current_version="1.2.1", + increment="MAJOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "2.0.0", + ), + # linear prerelease cases + ( + VersionSchemeTestArgs( + current_version="0.1.1b1", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "0.1.1b2", + ), + ( + VersionSchemeTestArgs( + current_version="0.1.1rc0", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "0.1.1rc1", + ), + ( + VersionSchemeTestArgs( + current_version="0.1.1rc0", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "0.1.1rc1", + ), + # prerelease cases + ( + VersionSchemeTestArgs( + current_version="3.3.3", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "3.3.4a0", + ), + ( + VersionSchemeTestArgs( + current_version="3.3.4a0", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "3.3.4a1", + ), + ( + VersionSchemeTestArgs( + current_version="3.3.4a1", + increment="MINOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "3.4.0a0", + ), + ( + VersionSchemeTestArgs( + current_version="3.4.0a0", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "3.4.0a1", + ), + ( + VersionSchemeTestArgs( + current_version="3.4.0a1", + increment="MINOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "3.4.0a2", + ), + ( + VersionSchemeTestArgs( + current_version="3.4.0a2", + increment="MAJOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "4.0.0a0", + ), + ( + VersionSchemeTestArgs( + current_version="4.0.0a0", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "4.0.0a1", + ), + ( + VersionSchemeTestArgs( + current_version="4.0.0a1", + increment="MINOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "4.0.0a2", + ), + ( + VersionSchemeTestArgs( + current_version="4.0.0a2", + increment="MAJOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "4.0.0a3", + ), + # + ( + VersionSchemeTestArgs( + current_version="1.0.0", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.1a0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.1a0", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.1a1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.1a1", + increment="MINOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.1.0a0", + ), + ( + VersionSchemeTestArgs( + current_version="1.1.0a0", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.1.0a1", + ), + ( + VersionSchemeTestArgs( + current_version="1.1.0a1", + increment="MINOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.1.0a2", + ), + ( + VersionSchemeTestArgs( + current_version="1.1.0a2", + increment="MAJOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "2.0.0a0", + ), + # + ( + VersionSchemeTestArgs( + current_version="1.0.0", + increment="MINOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.1.0a0", + ), + ( + VersionSchemeTestArgs( + current_version="1.1.0a0", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.1.0a1", + ), + ( + VersionSchemeTestArgs( + current_version="1.1.0a1", + increment="MINOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.1.0a2", + ), + ( + VersionSchemeTestArgs( + current_version="1.1.0a2", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.1.0a3", + ), + ( + VersionSchemeTestArgs( + current_version="1.1.0a3", + increment="MAJOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "2.0.0a0", + ), + # + ( + VersionSchemeTestArgs( + current_version="1.0.0", + increment="MAJOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "2.0.0a0", + ), + ( + VersionSchemeTestArgs( + current_version="2.0.0a0", + increment="MINOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "2.0.0a1", + ), + ( + VersionSchemeTestArgs( + current_version="2.0.0a1", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "2.0.0a2", + ), + ( + VersionSchemeTestArgs( + current_version="2.0.0a2", + increment="MAJOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "2.0.0a3", + ), + ( + VersionSchemeTestArgs( + current_version="2.0.0a3", + increment="MINOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "2.0.0a4", + ), + ( + VersionSchemeTestArgs( + current_version="2.0.0a4", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "2.0.0a5", + ), + ( + VersionSchemeTestArgs( + current_version="2.0.0a5", + increment="MAJOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "2.0.0a6", + ), + # + ( + VersionSchemeTestArgs( + current_version="2.0.0b0", + increment="MINOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "2.0.0b1", + ), + ( + VersionSchemeTestArgs( + current_version="2.0.0b0", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "2.0.0b1", + ), + # + ( + VersionSchemeTestArgs( + current_version="1.0.1a0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.0.1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.1a0", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.1.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.1a0", + increment="MAJOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "2.0.0", + ), + # + ( + VersionSchemeTestArgs( + current_version="1.1.0a0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.1.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.1.0a0", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.1.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.1.0a0", + increment="MAJOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "2.0.0", + ), + # + ( + VersionSchemeTestArgs( + current_version="2.0.0a0", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "2.0.0", + ), + ( + VersionSchemeTestArgs( + current_version="2.0.0a0", + increment="MAJOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "2.0.0", + ), + ( + VersionSchemeTestArgs( + current_version="2.0.0a0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "2.0.0", + ), + # + ( + VersionSchemeTestArgs( + current_version="3.0.0a1", + increment=None, + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "3.0.0", + ), + ( + VersionSchemeTestArgs( + current_version="3.0.0b1", + increment=None, + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "3.0.0", + ), + ( + VersionSchemeTestArgs( + current_version="3.0.0rc1", + increment=None, + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "3.0.0", + ), + # + ( + VersionSchemeTestArgs( + current_version="3.1.4", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "3.1.4a0", + ), + ( + VersionSchemeTestArgs( + current_version="3.1.4", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "3.1.4b0", + ), + ( + VersionSchemeTestArgs( + current_version="3.1.4", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "3.1.4rc0", + ), + # + ( + VersionSchemeTestArgs( + current_version="3.1.4", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "3.1.4a0", + ), + ( + VersionSchemeTestArgs( + current_version="3.1.4a0", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "3.1.4a1", + ), + ( + VersionSchemeTestArgs( + current_version="3.1.4a0", + increment="MINOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "3.2.0a0", + ), + ( + VersionSchemeTestArgs( + current_version="3.1.4a0", + increment="MAJOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "4.0.0a0", + ), + ], ) -def test_bump_pep440_version(test_input, expected): - current_version = test_input[0] - increment = test_input[1] - prerelease = test_input[2] - prerelease_offset = test_input[3] - devrelease = test_input[4] +def test_bump_pep440_version(version_args, expected_version): assert ( str( - Pep440(current_version).bump( - increment=increment, - prerelease=prerelease, - prerelease_offset=prerelease_offset, - devrelease=devrelease, + Pep440(version_args.current_version).bump( + increment=version_args.increment, + prerelease=version_args.prerelease, + prerelease_offset=version_args.prerelease_offset, + devrelease=version_args.devrelease, ) ) - == expected + == expected_version ) -@pytest.mark.parametrize("test_input, expected", exact_cases) -def test_bump_pep440_version_force(test_input, expected): - current_version = test_input[0] - increment = test_input[1] - prerelease = test_input[2] - prerelease_offset = test_input[3] - devrelease = test_input[4] +@pytest.mark.parametrize( + "version_args, expected_version", + [ + ( + VersionSchemeTestArgs( + current_version="1.0.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.0.1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.1.0", + ), + # with exact_increment=False: "1.0.0b0" + ( + VersionSchemeTestArgs( + current_version="1.0.0a1", + increment="PATCH", + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.1b0", + ), + # with exact_increment=False: "1.0.0b1" + ( + VersionSchemeTestArgs( + current_version="1.0.0b0", + increment="PATCH", + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.1b0", + ), + # with exact_increment=False: "1.0.0rc0" + ( + VersionSchemeTestArgs( + current_version="1.0.0b1", + increment="PATCH", + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.1rc0", + ), + # with exact_increment=False: "1.0.0-rc1" + ( + VersionSchemeTestArgs( + current_version="1.0.0rc0", + increment="PATCH", + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.1rc0", + ), + # with exact_increment=False: "1.0.0rc1-dev1" + ( + VersionSchemeTestArgs( + current_version="1.0.0rc0", + increment="PATCH", + prerelease="rc", + prerelease_offset=0, + devrelease=1, + ), + "1.0.1rc0.dev1", + ), + # with exact_increment=False: "1.0.0b0" + ( + VersionSchemeTestArgs( + current_version="1.0.0a1", + increment="MINOR", + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.1.0b0", + ), + # with exact_increment=False: "1.0.0b1" + ( + VersionSchemeTestArgs( + current_version="1.0.0b0", + increment="MINOR", + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.1.0b0", + ), + # with exact_increment=False: "1.0.0b1" + ( + VersionSchemeTestArgs( + current_version="1.0.0b0", + increment="MINOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.1.0a0", + ), + # with exact_increment=False: "1.0.0rc0" + ( + VersionSchemeTestArgs( + current_version="1.0.0b1", + increment="MINOR", + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.1.0rc0", + ), + # with exact_increment=False: "1.0.0rc1" + ( + VersionSchemeTestArgs( + current_version="1.0.0rc0", + increment="MINOR", + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.1.0rc0", + ), + # with exact_increment=False: "1.0.0rc1-dev1" + ( + VersionSchemeTestArgs( + current_version="1.0.0rc0", + increment="MINOR", + prerelease="rc", + prerelease_offset=0, + devrelease=1, + ), + "1.1.0rc0.dev1", + ), + # with exact_increment=False: "2.0.0" + ( + VersionSchemeTestArgs( + current_version="2.0.0b0", + increment="MAJOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "3.0.0", + ), + # with exact_increment=False: "2.0.0" + ( + VersionSchemeTestArgs( + current_version="2.0.0b0", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "2.1.0", + ), + # with exact_increment=False: "2.0.0" + ( + VersionSchemeTestArgs( + current_version="2.0.0b0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "2.0.1", + ), + # same with exact_increment=False + ( + VersionSchemeTestArgs( + current_version="2.0.0b0", + increment="MAJOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "3.0.0a0", + ), + # with exact_increment=False: "2.0.0b1" + ( + VersionSchemeTestArgs( + current_version="2.0.0b0", + increment="MINOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "2.1.0a0", + ), + # with exact_increment=False: "2.0.0b1" + ( + VersionSchemeTestArgs( + current_version="2.0.0b0", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "2.0.1a0", + ), + ], +) +def test_bump_pep440_version_force(version_args, expected_version): assert ( str( - Pep440(current_version).bump( - increment=increment, - prerelease=prerelease, - prerelease_offset=prerelease_offset, - devrelease=devrelease, + Pep440(version_args.current_version).bump( + increment=version_args.increment, + prerelease=version_args.prerelease, + prerelease_offset=version_args.prerelease_offset, + devrelease=version_args.devrelease, exact_increment=True, ) ) - == expected + == expected_version ) -@pytest.mark.parametrize("test_input,expected", local_versions) -def test_bump_pep440_version_local(test_input, expected): - current_version = test_input[0] - increment = test_input[1] - prerelease = test_input[2] - prerelease_offset = test_input[3] - devrelease = test_input[4] - is_local_version = True +@pytest.mark.parametrize( + "version_args, expected_version", + [ + ( + VersionSchemeTestArgs( + current_version="4.5.0+0.1.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "4.5.0+0.1.1", + ), + ( + VersionSchemeTestArgs( + current_version="4.5.0+0.1.1", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "4.5.0+0.2.0", + ), + ( + VersionSchemeTestArgs( + current_version="4.5.0+0.2.0", + increment="MAJOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "4.5.0+1.0.0", + ), + ], +) +def test_bump_pep440_version_local(version_args, expected_version): assert ( str( - Pep440(current_version).bump( - increment=increment, - prerelease=prerelease, - prerelease_offset=prerelease_offset, - devrelease=devrelease, - is_local_version=is_local_version, + Pep440(version_args.current_version).bump( + increment=version_args.increment, + prerelease=version_args.prerelease, + prerelease_offset=version_args.prerelease_offset, + devrelease=version_args.devrelease, + is_local_version=True, ) ) - == expected + == expected_version ) @@ -263,76 +1361,3 @@ def test_pep440_scheme_property(): def test_pep440_implement_version_protocol(): assert isinstance(Pep440("0.0.1"), VersionProtocol) - - -def test_pep440_sortable(): - test_input = [x[0][0] for x in simple_flow] - test_input.extend([x[1] for x in simple_flow]) - # randomize - random_input = [Pep440(x) for x in random.sample(test_input, len(test_input))] - assert len(random_input) == len(test_input) - sorted_result = [str(x) for x in sorted(random_input)] - assert sorted_result == [ - "0.1.0", - "0.1.0", - "0.1.1.dev1", - "0.1.1", - "0.1.1", - "0.2.0", - "0.2.0", - "0.2.0", - "0.3.0.dev1", - "0.3.0", - "0.3.0", - "0.3.0", - "0.3.0", - "0.3.1a0", - "0.3.1a0", - "0.3.1a0", - "0.3.1a0", - "0.3.1a1", - "0.3.1a1", - "0.3.1a1", - "0.3.1", - "0.3.1", - "0.3.1", - "0.3.2", - "0.4.2", - "1.0.0a0", - "1.0.0a0", - "1.0.0a1", - "1.0.0a1", - "1.0.0a1", - "1.0.0a1", - "1.0.0a2.dev0", - "1.0.0a2.dev0", - "1.0.0a2.dev1", - "1.0.0a2", - "1.0.0a3.dev0", - "1.0.0a3.dev0", - "1.0.0a3.dev1", - "1.0.0b0", - "1.0.0b0", - "1.0.0b0", - "1.0.0b1", - "1.0.0b1", - "1.0.0rc0", - "1.0.0rc0", - "1.0.0rc0", - "1.0.0rc0", - "1.0.0rc1.dev1", - "1.0.0rc1", - "1.0.0", - "1.0.0", - "1.0.1", - "1.0.1", - "1.0.2", - "1.0.2", - "1.1.0", - "1.1.0", - "1.2.0", - "1.2.0", - "1.2.1", - "1.2.1", - "2.0.0", - ] diff --git a/tests/test_version_scheme_semver.py b/tests/test_version_scheme_semver.py index 8785717a3..8a163d4f6 100644 --- a/tests/test_version_scheme_semver.py +++ b/tests/test_version_scheme_semver.py @@ -1,189 +1,882 @@ -import itertools -import random +from __future__ import annotations import pytest from commitizen.version_schemes import SemVer, VersionProtocol - -simple_flow = [ - (("0.1.0", "PATCH", None, 0, None), "0.1.1"), - (("0.1.0", "PATCH", None, 0, 1), "0.1.1-dev1"), - (("0.1.1", "MINOR", None, 0, None), "0.2.0"), - (("0.2.0", "MINOR", None, 0, None), "0.3.0"), - (("0.2.0", "MINOR", None, 0, 1), "0.3.0-dev1"), - (("0.3.0", "PATCH", None, 0, None), "0.3.1"), - (("0.3.0", "PATCH", "alpha", 0, None), "0.3.1-a0"), - (("0.3.1a0", None, "alpha", 0, None), "0.3.1-a1"), - (("0.3.0", "PATCH", "alpha", 1, None), "0.3.1-a1"), - (("0.3.1a0", None, "alpha", 1, None), "0.3.1-a1"), - (("0.3.1a0", None, None, 0, None), "0.3.1"), - (("0.3.1", "PATCH", None, 0, None), "0.3.2"), - (("0.4.2", "MAJOR", "alpha", 0, None), "1.0.0-a0"), - (("1.0.0a0", None, "alpha", 0, None), "1.0.0-a1"), - (("1.0.0a1", None, "alpha", 0, None), "1.0.0-a2"), - (("1.0.0a1", None, "alpha", 0, 1), "1.0.0-a2-dev1"), - (("1.0.0a2.dev0", None, "alpha", 0, 1), "1.0.0-a3-dev1"), - (("1.0.0a2.dev0", None, "alpha", 0, 0), "1.0.0-a3-dev0"), - (("1.0.0a1", None, "beta", 0, None), "1.0.0-b0"), - (("1.0.0b0", None, "beta", 0, None), "1.0.0-b1"), - (("1.0.0b1", None, "rc", 0, None), "1.0.0-rc0"), - (("1.0.0rc0", None, "rc", 0, None), "1.0.0-rc1"), - (("1.0.0rc0", None, "rc", 0, 1), "1.0.0-rc1-dev1"), - (("1.0.0rc0", "PATCH", None, 0, None), "1.0.0"), - (("1.0.0a3.dev0", None, "beta", 0, None), "1.0.0-b0"), - (("1.0.0", "PATCH", None, 0, None), "1.0.1"), - (("1.0.1", "PATCH", None, 0, None), "1.0.2"), - (("1.0.2", "MINOR", None, 0, None), "1.1.0"), - (("1.1.0", "MINOR", None, 0, None), "1.2.0"), - (("1.2.0", "PATCH", None, 0, None), "1.2.1"), - (("1.2.1", "MAJOR", None, 0, None), "2.0.0"), -] - -local_versions = [ - (("4.5.0+0.1.0", "PATCH", None, 0, None), "4.5.0+0.1.1"), - (("4.5.0+0.1.1", "MINOR", None, 0, None), "4.5.0+0.2.0"), - (("4.5.0+0.2.0", "MAJOR", None, 0, None), "4.5.0+1.0.0"), -] - -# never bump backwards on pre-releases -linear_prerelease_cases = [ - (("0.1.1b1", None, "alpha", 0, None), "0.1.1-b2"), - (("0.1.1rc0", None, "alpha", 0, None), "0.1.1-rc1"), - (("0.1.1rc0", None, "beta", 0, None), "0.1.1-rc1"), -] - -weird_cases = [ - (("1.1", "PATCH", None, 0, None), "1.1.1"), - (("1", "MINOR", None, 0, None), "1.1.0"), - (("1", "MAJOR", None, 0, None), "2.0.0"), - (("1a0", None, "alpha", 0, None), "1.0.0-a1"), - (("1a0", None, "alpha", 1, None), "1.0.0-a1"), - (("1", None, "beta", 0, None), "1.0.0-b0"), - (("1", None, "beta", 1, None), "1.0.0-b1"), - (("1beta", None, "beta", 0, None), "1.0.0-b1"), - (("1.0.0alpha1", None, "alpha", 0, None), "1.0.0-a2"), - (("1", None, "rc", 0, None), "1.0.0-rc0"), - (("1.0.0rc1+e20d7b57f3eb", "PATCH", None, 0, None), "1.0.0"), -] - -# test driven development -tdd_cases = [ - (("0.1.1", "PATCH", None, 0, None), "0.1.2"), - (("0.1.1", "MINOR", None, 0, None), "0.2.0"), - (("2.1.1", "MAJOR", None, 0, None), "3.0.0"), - (("0.9.0", "PATCH", "alpha", 0, None), "0.9.1-a0"), - (("0.9.0", "MINOR", "alpha", 0, None), "0.10.0-a0"), - (("0.9.0", "MAJOR", "alpha", 0, None), "1.0.0-a0"), - (("0.9.0", "MAJOR", "alpha", 1, None), "1.0.0-a1"), - (("1.0.0a2", None, "beta", 0, None), "1.0.0-b0"), - (("1.0.0a2", None, "beta", 1, None), "1.0.0-b1"), - (("1.0.0beta1", None, "rc", 0, None), "1.0.0-rc0"), - (("1.0.0rc1", None, "rc", 0, None), "1.0.0-rc2"), - (("1.0.0-a0", None, "rc", 0, None), "1.0.0-rc0"), - (("1.0.0-alpha1", None, "alpha", 0, None), "1.0.0-a2"), -] - -exact_cases = [ - (("1.0.0", "PATCH", None, 0, None), "1.0.1"), - (("1.0.0", "MINOR", None, 0, None), "1.1.0"), - # with exact_increment=False: "1.0.0-b0" - (("1.0.0a1", "PATCH", "beta", 0, None), "1.0.1-b0"), - # with exact_increment=False: "1.0.0-b1" - (("1.0.0b0", "PATCH", "beta", 0, None), "1.0.1-b0"), - # with exact_increment=False: "1.0.0-rc0" - (("1.0.0b1", "PATCH", "rc", 0, None), "1.0.1-rc0"), - # with exact_increment=False: "1.0.0-rc1" - (("1.0.0rc0", "PATCH", "rc", 0, None), "1.0.1-rc0"), - # with exact_increment=False: "1.0.0-rc1-dev1" - (("1.0.0rc0", "PATCH", "rc", 0, 1), "1.0.1-rc0-dev1"), - # with exact_increment=False: "1.0.0-b0" - (("1.0.0a1", "MINOR", "beta", 0, None), "1.1.0-b0"), - # with exact_increment=False: "1.0.0-b1" - (("1.0.0b0", "MINOR", "beta", 0, None), "1.1.0-b0"), - # with exact_increment=False: "1.0.0-rc0" - (("1.0.0b1", "MINOR", "rc", 0, None), "1.1.0-rc0"), - # with exact_increment=False: "1.0.0-rc1" - (("1.0.0rc0", "MINOR", "rc", 0, None), "1.1.0-rc0"), - # with exact_increment=False: "1.0.0-rc1-dev1" - (("1.0.0rc0", "MINOR", "rc", 0, 1), "1.1.0-rc0-dev1"), - # with exact_increment=False: "2.0.0" - (("2.0.0b0", "MAJOR", None, 0, None), "3.0.0"), - # with exact_increment=False: "2.0.0" - (("2.0.0b0", "MINOR", None, 0, None), "2.1.0"), - # with exact_increment=False: "2.0.0" - (("2.0.0b0", "PATCH", None, 0, None), "2.0.1"), - # same with exact_increment=False - (("2.0.0b0", "MAJOR", "alpha", 0, None), "3.0.0-a0"), - # with exact_increment=False: "2.0.0b1" - (("2.0.0b0", "MINOR", "alpha", 0, None), "2.1.0-a0"), - # with exact_increment=False: "2.0.0b1" - (("2.0.0b0", "PATCH", "alpha", 0, None), "2.0.1-a0"), -] +from tests.utils import VersionSchemeTestArgs @pytest.mark.parametrize( - "test_input, expected", - itertools.chain(tdd_cases, weird_cases, simple_flow, linear_prerelease_cases), + "version_args, expected_version", + [ + ( + VersionSchemeTestArgs( + current_version="0.1.1", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.1.2", + ), + ( + VersionSchemeTestArgs( + current_version="0.1.1", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.2.0", + ), + ( + VersionSchemeTestArgs( + current_version="2.1.1", + increment="MAJOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "3.0.0", + ), + ( + VersionSchemeTestArgs( + current_version="0.9.0", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "0.9.1-a0", + ), + ( + VersionSchemeTestArgs( + current_version="0.9.0", + increment="MINOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "0.10.0-a0", + ), + ( + VersionSchemeTestArgs( + current_version="0.9.0", + increment="MAJOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-a0", + ), + ( + VersionSchemeTestArgs( + current_version="0.9.0", + increment="MAJOR", + prerelease="alpha", + prerelease_offset=1, + devrelease=None, + ), + "1.0.0-a1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0a2", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-b0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0a2", + increment=None, + prerelease="beta", + prerelease_offset=1, + devrelease=None, + ), + "1.0.0-b1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0beta1", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-rc0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0rc1", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-rc2", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-a0", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-rc0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-alpha1", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-a2", + ), + # weird cases + ( + VersionSchemeTestArgs( + current_version="1.1", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.1.1", + ), + ( + VersionSchemeTestArgs( + current_version="1", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.1.0", + ), + ( + VersionSchemeTestArgs( + current_version="1", + increment="MAJOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "2.0.0", + ), + ( + VersionSchemeTestArgs( + current_version="1a0", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-a1", + ), + ( + VersionSchemeTestArgs( + current_version="1a0", + increment=None, + prerelease="alpha", + prerelease_offset=1, + devrelease=None, + ), + "1.0.0-a1", + ), + ( + VersionSchemeTestArgs( + current_version="1", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-b0", + ), + ( + VersionSchemeTestArgs( + current_version="1", + increment=None, + prerelease="beta", + prerelease_offset=1, + devrelease=None, + ), + "1.0.0-b1", + ), + ( + VersionSchemeTestArgs( + current_version="1beta", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-b1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0alpha1", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-a2", + ), + ( + VersionSchemeTestArgs( + current_version="1", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-rc0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0rc1+e20d7b57f3eb", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.0.0", + ), + # simple flow + ( + VersionSchemeTestArgs( + current_version="0.1.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.1.1", + ), + ( + VersionSchemeTestArgs( + current_version="0.1.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=1, + ), + "0.1.1-dev1", + ), + ( + VersionSchemeTestArgs( + current_version="0.1.1", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.2.0", + ), + ( + VersionSchemeTestArgs( + current_version="0.2.0", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.3.0", + ), + ( + VersionSchemeTestArgs( + current_version="0.2.0", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=1, + ), + "0.3.0-dev1", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.3.1", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.0", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "0.3.1-a0", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.1a0", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "0.3.1-a1", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.0", + increment="PATCH", + prerelease="alpha", + prerelease_offset=1, + devrelease=None, + ), + "0.3.1-a1", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.1a0", + increment=None, + prerelease="alpha", + prerelease_offset=1, + devrelease=None, + ), + "0.3.1-a1", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.1a0", + increment=None, + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.3.1", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.1", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.3.2", + ), + ( + VersionSchemeTestArgs( + current_version="0.4.2", + increment="MAJOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-a0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0a0", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-a1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0a1", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-a2", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0a1", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=1, + ), + "1.0.0-a2-dev1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0a2.dev0", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=1, + ), + "1.0.0-a3-dev1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0a2.dev0", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=0, + ), + "1.0.0-a3-dev0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0a1", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-b0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0b0", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-b1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0b1", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-rc0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0rc0", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-rc1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0rc0", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=1, + ), + "1.0.0-rc1-dev1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0rc0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.0.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0a3.dev0", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-b0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.0.1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.1", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.0.2", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.2", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.1.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.1.0", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.2.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.2.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.2.1", + ), + ( + VersionSchemeTestArgs( + current_version="1.2.1", + increment="MAJOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "2.0.0", + ), + # linear prerelease cases (never bump backwards on pre-releases) + ( + VersionSchemeTestArgs( + current_version="0.1.1b1", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "0.1.1-b2", + ), + ( + VersionSchemeTestArgs( + current_version="0.1.1rc0", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "0.1.1-rc1", + ), + ( + VersionSchemeTestArgs( + current_version="0.1.1rc0", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "0.1.1-rc1", + ), + ], ) -def test_bump_semver_version(test_input, expected): - current_version = test_input[0] - increment = test_input[1] - prerelease = test_input[2] - prerelease_offset = test_input[3] - devrelease = test_input[4] +def test_bump_semver_version( + version_args: VersionSchemeTestArgs, expected_version: str +): assert ( str( - SemVer(current_version).bump( - increment=increment, - prerelease=prerelease, - prerelease_offset=prerelease_offset, - devrelease=devrelease, + SemVer(version_args.current_version).bump( + increment=version_args.increment, + prerelease=version_args.prerelease, + prerelease_offset=version_args.prerelease_offset, + devrelease=version_args.devrelease, ) ) - == expected + == expected_version ) -@pytest.mark.parametrize("test_input, expected", exact_cases) -def test_bump_semver_version_force(test_input, expected): - current_version = test_input[0] - increment = test_input[1] - prerelease = test_input[2] - prerelease_offset = test_input[3] - devrelease = test_input[4] +@pytest.mark.parametrize( + "version_args, expected_version", + [ + ( + VersionSchemeTestArgs( + current_version="1.0.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.0.1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.1.0", + ), + # with exact_increment=False: "1.0.0-b0" + ( + VersionSchemeTestArgs( + current_version="1.0.0a1", + increment="PATCH", + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.1-b0", + ), + # with exact_increment=False: "1.0.0-b1" + ( + VersionSchemeTestArgs( + current_version="1.0.0b0", + increment="PATCH", + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.1-b0", + ), + # with exact_increment=False: "1.0.0-rc0" + ( + VersionSchemeTestArgs( + current_version="1.0.0b1", + increment="PATCH", + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.1-rc0", + ), + # with exact_increment=False: "1.0.0-rc1" + ( + VersionSchemeTestArgs( + current_version="1.0.0rc0", + increment="PATCH", + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.1-rc0", + ), + # with exact_increment=False: "1.0.0-rc1-dev1" + ( + VersionSchemeTestArgs( + current_version="1.0.0rc0", + increment="PATCH", + prerelease="rc", + prerelease_offset=0, + devrelease=1, + ), + "1.0.1-rc0-dev1", + ), + # with exact_increment=False: "1.0.0-b0" + ( + VersionSchemeTestArgs( + current_version="1.0.0a1", + increment="MINOR", + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.1.0-b0", + ), + # with exact_increment=False: "1.0.0-b1" + ( + VersionSchemeTestArgs( + current_version="1.0.0b0", + increment="MINOR", + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.1.0-b0", + ), + # with exact_increment=False: "1.0.0-rc0" + ( + VersionSchemeTestArgs( + current_version="1.0.0b1", + increment="MINOR", + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.1.0-rc0", + ), + # with exact_increment=False: "1.0.0-rc1" + ( + VersionSchemeTestArgs( + current_version="1.0.0rc0", + increment="MINOR", + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.1.0-rc0", + ), + # with exact_increment=False: "1.0.0-rc1-dev1" + ( + VersionSchemeTestArgs( + current_version="1.0.0rc0", + increment="MINOR", + prerelease="rc", + prerelease_offset=0, + devrelease=1, + ), + "1.1.0-rc0-dev1", + ), + # with exact_increment=False: "2.0.0" + ( + VersionSchemeTestArgs( + current_version="2.0.0b0", + increment="MAJOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "3.0.0", + ), + # with exact_increment=False: "2.0.0" + ( + VersionSchemeTestArgs( + current_version="2.0.0b0", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "2.1.0", + ), + # with exact_increment=False: "2.0.0" + ( + VersionSchemeTestArgs( + current_version="2.0.0b0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "2.0.1", + ), + # same with exact_increment=False + ( + VersionSchemeTestArgs( + current_version="2.0.0b0", + increment="MAJOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "3.0.0-a0", + ), + # with exact_increment=False: "2.0.0b1" + ( + VersionSchemeTestArgs( + current_version="2.0.0b0", + increment="MINOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "2.1.0-a0", + ), + # with exact_increment=False: "2.0.0b1" + ( + VersionSchemeTestArgs( + current_version="2.0.0b0", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "2.0.1-a0", + ), + ], +) +def test_bump_semver_version_force( + version_args: VersionSchemeTestArgs, expected_version: str +): assert ( str( - SemVer(current_version).bump( - increment=increment, - prerelease=prerelease, - prerelease_offset=prerelease_offset, - devrelease=devrelease, + SemVer(version_args.current_version).bump( + increment=version_args.increment, + prerelease=version_args.prerelease, + prerelease_offset=version_args.prerelease_offset, + devrelease=version_args.devrelease, exact_increment=True, ) ) - == expected + == expected_version ) -@pytest.mark.parametrize("test_input,expected", local_versions) -def test_bump_semver_version_local(test_input, expected): - current_version = test_input[0] - increment = test_input[1] - prerelease = test_input[2] - prerelease_offset = test_input[3] - devrelease = test_input[4] - is_local_version = True +@pytest.mark.parametrize( + "version_args, expected_version", + [ + ( + VersionSchemeTestArgs( + current_version="4.5.0+0.1.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "4.5.0+0.1.1", + ), + ( + VersionSchemeTestArgs( + current_version="4.5.0+0.1.1", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "4.5.0+0.2.0", + ), + ( + VersionSchemeTestArgs( + current_version="4.5.0+0.2.0", + increment="MAJOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "4.5.0+1.0.0", + ), + ], +) +def test_bump_semver_version_local( + version_args: VersionSchemeTestArgs, expected_version: str +): assert ( str( - SemVer(current_version).bump( - increment=increment, - prerelease=prerelease, - prerelease_offset=prerelease_offset, - devrelease=devrelease, - is_local_version=is_local_version, + SemVer(version_args.current_version).bump( + increment=version_args.increment, + prerelease=version_args.prerelease, + prerelease_offset=version_args.prerelease_offset, + devrelease=version_args.devrelease, + is_local_version=True, ) ) - == expected + == expected_version ) @@ -194,76 +887,3 @@ def test_semver_scheme_property(): def test_semver_implement_version_protocol(): assert isinstance(SemVer("0.0.1"), VersionProtocol) - - -def test_semver_sortable(): - test_input = [x[0][0] for x in simple_flow] - test_input.extend([x[1] for x in simple_flow]) - # randomize - random_input = [SemVer(x) for x in random.sample(test_input, len(test_input))] - assert len(random_input) == len(test_input) - sorted_result = [str(x) for x in sorted(random_input)] - assert sorted_result == [ - "0.1.0", - "0.1.0", - "0.1.1-dev1", - "0.1.1", - "0.1.1", - "0.2.0", - "0.2.0", - "0.2.0", - "0.3.0-dev1", - "0.3.0", - "0.3.0", - "0.3.0", - "0.3.0", - "0.3.1-a0", - "0.3.1-a0", - "0.3.1-a0", - "0.3.1-a0", - "0.3.1-a1", - "0.3.1-a1", - "0.3.1-a1", - "0.3.1", - "0.3.1", - "0.3.1", - "0.3.2", - "0.4.2", - "1.0.0-a0", - "1.0.0-a0", - "1.0.0-a1", - "1.0.0-a1", - "1.0.0-a1", - "1.0.0-a1", - "1.0.0-a2-dev0", - "1.0.0-a2-dev0", - "1.0.0-a2-dev1", - "1.0.0-a2", - "1.0.0-a3-dev0", - "1.0.0-a3-dev0", - "1.0.0-a3-dev1", - "1.0.0-b0", - "1.0.0-b0", - "1.0.0-b0", - "1.0.0-b1", - "1.0.0-b1", - "1.0.0-rc0", - "1.0.0-rc0", - "1.0.0-rc0", - "1.0.0-rc0", - "1.0.0-rc1-dev1", - "1.0.0-rc1", - "1.0.0", - "1.0.0", - "1.0.1", - "1.0.1", - "1.0.2", - "1.0.2", - "1.1.0", - "1.1.0", - "1.2.0", - "1.2.0", - "1.2.1", - "1.2.1", - "2.0.0", - ] diff --git a/tests/test_version_scheme_semver2.py b/tests/test_version_scheme_semver2.py index d18a058a7..4a35e6470 100644 --- a/tests/test_version_scheme_semver2.py +++ b/tests/test_version_scheme_semver2.py @@ -1,131 +1,664 @@ -import itertools -import random +from __future__ import annotations import pytest from commitizen.version_schemes import SemVer2, VersionProtocol - -simple_flow = [ - (("0.1.0", "PATCH", None, 0, None), "0.1.1"), - (("0.1.0", "PATCH", None, 0, 1), "0.1.1-dev.1"), - (("0.1.1", "MINOR", None, 0, None), "0.2.0"), - (("0.2.0", "MINOR", None, 0, None), "0.3.0"), - (("0.2.0", "MINOR", None, 0, 1), "0.3.0-dev.1"), - (("0.3.0", "PATCH", None, 0, None), "0.3.1"), - (("0.3.0", "PATCH", "alpha", 0, None), "0.3.1-alpha.0"), - (("0.3.1-alpha.0", None, "alpha", 0, None), "0.3.1-alpha.1"), - (("0.3.0", "PATCH", "alpha", 1, None), "0.3.1-alpha.1"), - (("0.3.1-alpha.0", None, "alpha", 1, None), "0.3.1-alpha.1"), - (("0.3.1-alpha.0", None, None, 0, None), "0.3.1"), - (("0.3.1", "PATCH", None, 0, None), "0.3.2"), - (("0.4.2", "MAJOR", "alpha", 0, None), "1.0.0-alpha.0"), - (("1.0.0-alpha.0", None, "alpha", 0, None), "1.0.0-alpha.1"), - (("1.0.0-alpha.1", None, "alpha", 0, None), "1.0.0-alpha.2"), - (("1.0.0-alpha.1", None, "alpha", 0, 1), "1.0.0-alpha.2.dev.1"), - (("1.0.0-alpha.2.dev.0", None, "alpha", 0, 1), "1.0.0-alpha.3.dev.1"), - (("1.0.0-alpha.2.dev.0", None, "alpha", 0, 0), "1.0.0-alpha.3.dev.0"), - (("1.0.0-alpha.1", None, "beta", 0, None), "1.0.0-beta.0"), - (("1.0.0-beta.0", None, "beta", 0, None), "1.0.0-beta.1"), - (("1.0.0-beta.1", None, "rc", 0, None), "1.0.0-rc.0"), - (("1.0.0-rc.0", None, "rc", 0, None), "1.0.0-rc.1"), - (("1.0.0-rc.0", None, "rc", 0, 1), "1.0.0-rc.1.dev.1"), - (("1.0.0-rc.0", "PATCH", None, 0, None), "1.0.0"), - (("1.0.0-alpha.3.dev.0", None, "beta", 0, None), "1.0.0-beta.0"), - (("1.0.0", "PATCH", None, 0, None), "1.0.1"), - (("1.0.1", "PATCH", None, 0, None), "1.0.2"), - (("1.0.2", "MINOR", None, 0, None), "1.1.0"), - (("1.1.0", "MINOR", None, 0, None), "1.2.0"), - (("1.2.0", "PATCH", None, 0, None), "1.2.1"), - (("1.2.1", "MAJOR", None, 0, None), "2.0.0"), -] - -local_versions = [ - (("4.5.0+0.1.0", "PATCH", None, 0, None), "4.5.0+0.1.1"), - (("4.5.0+0.1.1", "MINOR", None, 0, None), "4.5.0+0.2.0"), - (("4.5.0+0.2.0", "MAJOR", None, 0, None), "4.5.0+1.0.0"), -] - -# never bump backwards on pre-releases -linear_prerelease_cases = [ - (("0.1.1-beta.1", None, "alpha", 0, None), "0.1.1-beta.2"), - (("0.1.1-rc.0", None, "alpha", 0, None), "0.1.1-rc.1"), - (("0.1.1-rc.0", None, "beta", 0, None), "0.1.1-rc.1"), -] - -weird_cases = [ - (("1.1", "PATCH", None, 0, None), "1.1.1"), - (("1", "MINOR", None, 0, None), "1.1.0"), - (("1", "MAJOR", None, 0, None), "2.0.0"), - (("1-alpha.0", None, "alpha", 0, None), "1.0.0-alpha.1"), - (("1-alpha.0", None, "alpha", 1, None), "1.0.0-alpha.1"), - (("1", None, "beta", 0, None), "1.0.0-beta.0"), - (("1", None, "beta", 1, None), "1.0.0-beta.1"), - (("1-beta", None, "beta", 0, None), "1.0.0-beta.1"), - (("1.0.0-alpha.1", None, "alpha", 0, None), "1.0.0-alpha.2"), - (("1", None, "rc", 0, None), "1.0.0-rc.0"), - (("1.0.0-rc.1+e20d7b57f3eb", "PATCH", None, 0, None), "1.0.0"), -] - -# test driven development -tdd_cases = [ - (("0.1.1", "PATCH", None, 0, None), "0.1.2"), - (("0.1.1", "MINOR", None, 0, None), "0.2.0"), - (("2.1.1", "MAJOR", None, 0, None), "3.0.0"), - (("0.9.0", "PATCH", "alpha", 0, None), "0.9.1-alpha.0"), - (("0.9.0", "MINOR", "alpha", 0, None), "0.10.0-alpha.0"), - (("0.9.0", "MAJOR", "alpha", 0, None), "1.0.0-alpha.0"), - (("0.9.0", "MAJOR", "alpha", 1, None), "1.0.0-alpha.1"), - (("1.0.0-alpha.2", None, "beta", 0, None), "1.0.0-beta.0"), - (("1.0.0-alpha.2", None, "beta", 1, None), "1.0.0-beta.1"), - (("1.0.0-beta.1", None, "rc", 0, None), "1.0.0-rc.0"), - (("1.0.0-rc.1", None, "rc", 0, None), "1.0.0-rc.2"), - (("1.0.0-alpha.0", None, "rc", 0, None), "1.0.0-rc.0"), - (("1.0.0-alpha.1", None, "alpha", 0, None), "1.0.0-alpha.2"), -] +from tests.utils import VersionSchemeTestArgs @pytest.mark.parametrize( - "test_input, expected", - itertools.chain(tdd_cases, weird_cases, simple_flow, linear_prerelease_cases), + "version_args, expected_version", + [ + ( + VersionSchemeTestArgs( + current_version="0.1.1", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.1.2", + ), + ( + VersionSchemeTestArgs( + current_version="0.1.1", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.2.0", + ), + ( + VersionSchemeTestArgs( + current_version="2.1.1", + increment="MAJOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "3.0.0", + ), + ( + VersionSchemeTestArgs( + current_version="0.9.0", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "0.9.1-alpha.0", + ), + ( + VersionSchemeTestArgs( + current_version="0.9.0", + increment="MINOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "0.10.0-alpha.0", + ), + ( + VersionSchemeTestArgs( + current_version="0.9.0", + increment="MAJOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-alpha.0", + ), + ( + VersionSchemeTestArgs( + current_version="0.9.0", + increment="MAJOR", + prerelease="alpha", + prerelease_offset=1, + devrelease=None, + ), + "1.0.0-alpha.1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-alpha.2", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-beta.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-alpha.2", + increment=None, + prerelease="beta", + prerelease_offset=1, + devrelease=None, + ), + "1.0.0-beta.1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-beta.1", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-rc.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-rc.1", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-rc.2", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-alpha.0", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-rc.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-alpha.1", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-alpha.2", + ), + # weird_cases + ( + VersionSchemeTestArgs( + current_version="1.1", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.1.1", + ), + ( + VersionSchemeTestArgs( + current_version="1", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.1.0", + ), + ( + VersionSchemeTestArgs( + current_version="1", + increment="MAJOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "2.0.0", + ), + ( + VersionSchemeTestArgs( + current_version="1-alpha.0", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-alpha.1", + ), + ( + VersionSchemeTestArgs( + current_version="1-alpha.0", + increment=None, + prerelease="alpha", + prerelease_offset=1, + devrelease=None, + ), + "1.0.0-alpha.1", + ), + ( + VersionSchemeTestArgs( + current_version="1", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-beta.0", + ), + ( + VersionSchemeTestArgs( + current_version="1", + increment=None, + prerelease="beta", + prerelease_offset=1, + devrelease=None, + ), + "1.0.0-beta.1", + ), + ( + VersionSchemeTestArgs( + current_version="1-beta", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-beta.1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-alpha.1", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-alpha.2", + ), + ( + VersionSchemeTestArgs( + current_version="1", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-rc.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-rc.1+e20d7b57f3eb", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.0.0", + ), + # simple_flow + ( + VersionSchemeTestArgs( + current_version="0.1.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.1.1", + ), + ( + VersionSchemeTestArgs( + current_version="0.1.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=1, + ), + "0.1.1-dev.1", + ), + ( + VersionSchemeTestArgs( + current_version="0.1.1", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.2.0", + ), + ( + VersionSchemeTestArgs( + current_version="0.2.0", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.3.0", + ), + ( + VersionSchemeTestArgs( + current_version="0.2.0", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=1, + ), + "0.3.0-dev.1", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.3.1", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.0", + increment="PATCH", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "0.3.1-alpha.0", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.1-alpha.0", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "0.3.1-alpha.1", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.0", + increment="PATCH", + prerelease="alpha", + prerelease_offset=1, + devrelease=None, + ), + "0.3.1-alpha.1", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.1-alpha.0", + increment=None, + prerelease="alpha", + prerelease_offset=1, + devrelease=None, + ), + "0.3.1-alpha.1", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.1-alpha.0", + increment=None, + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.3.1", + ), + ( + VersionSchemeTestArgs( + current_version="0.3.1", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "0.3.2", + ), + ( + VersionSchemeTestArgs( + current_version="0.4.2", + increment="MAJOR", + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-alpha.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-alpha.0", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-alpha.1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-alpha.1", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-alpha.2", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-alpha.1", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=1, + ), + "1.0.0-alpha.2.dev.1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-alpha.2.dev.0", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=1, + ), + "1.0.0-alpha.3.dev.1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-alpha.2.dev.0", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=0, + ), + "1.0.0-alpha.3.dev.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-alpha.1", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-beta.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-beta.0", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-beta.1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-beta.1", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-rc.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-rc.0", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-rc.1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-rc.0", + increment=None, + prerelease="rc", + prerelease_offset=0, + devrelease=1, + ), + "1.0.0-rc.1.dev.1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-rc.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.0.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0-alpha.3.dev.0", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "1.0.0-beta.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.0.1", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.1", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.0.2", + ), + ( + VersionSchemeTestArgs( + current_version="1.0.2", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.1.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.1.0", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.2.0", + ), + ( + VersionSchemeTestArgs( + current_version="1.2.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "1.2.1", + ), + ( + VersionSchemeTestArgs( + current_version="1.2.1", + increment="MAJOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "2.0.0", + ), + # linear prerelease cases (never bump backwards on pre-releases) + ( + VersionSchemeTestArgs( + current_version="0.1.1-beta.1", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "0.1.1-beta.2", + ), + ( + VersionSchemeTestArgs( + current_version="0.1.1-rc.0", + increment=None, + prerelease="alpha", + prerelease_offset=0, + devrelease=None, + ), + "0.1.1-rc.1", + ), + ( + VersionSchemeTestArgs( + current_version="0.1.1-rc.0", + increment=None, + prerelease="beta", + prerelease_offset=0, + devrelease=None, + ), + "0.1.1-rc.1", + ), + ], ) -def test_bump_semver_version(test_input, expected): - current_version = test_input[0] - increment = test_input[1] - prerelease = test_input[2] - prerelease_offset = test_input[3] - devrelease = test_input[4] +def test_bump_semver_version( + version_args: VersionSchemeTestArgs, expected_version: str +): assert ( str( - SemVer2(current_version).bump( - increment=increment, - prerelease=prerelease, - prerelease_offset=prerelease_offset, - devrelease=devrelease, + SemVer2(version_args.current_version).bump( + increment=version_args.increment, + prerelease=version_args.prerelease, + prerelease_offset=version_args.prerelease_offset, + devrelease=version_args.devrelease, ) ) - == expected + == expected_version ) -@pytest.mark.parametrize("test_input,expected", local_versions) -def test_bump_semver_version_local(test_input, expected): - current_version = test_input[0] - increment = test_input[1] - prerelease = test_input[2] - prerelease_offset = test_input[3] - devrelease = test_input[4] - is_local_version = True +@pytest.mark.parametrize( + "version_args, expected_version", + [ + ( + VersionSchemeTestArgs( + current_version="4.5.0+0.1.0", + increment="PATCH", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "4.5.0+0.1.1", + ), + ( + VersionSchemeTestArgs( + current_version="4.5.0+0.1.1", + increment="MINOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "4.5.0+0.2.0", + ), + ( + VersionSchemeTestArgs( + current_version="4.5.0+0.2.0", + increment="MAJOR", + prerelease=None, + prerelease_offset=0, + devrelease=None, + ), + "4.5.0+1.0.0", + ), + ], +) +def test_bump_semver_version_local( + version_args: VersionSchemeTestArgs, expected_version: str +): assert ( str( - SemVer2(current_version).bump( - increment=increment, - prerelease=prerelease, - prerelease_offset=prerelease_offset, - devrelease=devrelease, - is_local_version=is_local_version, + SemVer2(version_args.current_version).bump( + increment=version_args.increment, + prerelease=version_args.prerelease, + prerelease_offset=version_args.prerelease_offset, + devrelease=version_args.devrelease, + is_local_version=True, ) ) - == expected + == expected_version ) @@ -136,76 +669,3 @@ def test_semver_scheme_property(): def test_semver_implement_version_protocol(): assert isinstance(SemVer2("0.0.1"), VersionProtocol) - - -def test_semver_sortable(): - test_input = [x[0][0] for x in simple_flow] - test_input.extend([x[1] for x in simple_flow]) - # randomize - random_input = [SemVer2(x) for x in random.sample(test_input, len(test_input))] - assert len(random_input) == len(test_input) - sorted_result = [str(x) for x in sorted(random_input)] - assert sorted_result == [ - "0.1.0", - "0.1.0", - "0.1.1-dev.1", - "0.1.1", - "0.1.1", - "0.2.0", - "0.2.0", - "0.2.0", - "0.3.0-dev.1", - "0.3.0", - "0.3.0", - "0.3.0", - "0.3.0", - "0.3.1-alpha.0", - "0.3.1-alpha.0", - "0.3.1-alpha.0", - "0.3.1-alpha.0", - "0.3.1-alpha.1", - "0.3.1-alpha.1", - "0.3.1-alpha.1", - "0.3.1", - "0.3.1", - "0.3.1", - "0.3.2", - "0.4.2", - "1.0.0-alpha.0", - "1.0.0-alpha.0", - "1.0.0-alpha.1", - "1.0.0-alpha.1", - "1.0.0-alpha.1", - "1.0.0-alpha.1", - "1.0.0-alpha.2.dev.0", - "1.0.0-alpha.2.dev.0", - "1.0.0-alpha.2.dev.1", - "1.0.0-alpha.2", - "1.0.0-alpha.3.dev.0", - "1.0.0-alpha.3.dev.0", - "1.0.0-alpha.3.dev.1", - "1.0.0-beta.0", - "1.0.0-beta.0", - "1.0.0-beta.0", - "1.0.0-beta.1", - "1.0.0-beta.1", - "1.0.0-rc.0", - "1.0.0-rc.0", - "1.0.0-rc.0", - "1.0.0-rc.0", - "1.0.0-rc.1.dev.1", - "1.0.0-rc.1", - "1.0.0", - "1.0.0", - "1.0.1", - "1.0.1", - "1.0.2", - "1.0.2", - "1.1.0", - "1.1.0", - "1.2.0", - "1.2.0", - "1.2.1", - "1.2.1", - "2.0.0", - ] diff --git a/tests/utils.py b/tests/utils.py index 5e26b2d70..43e0cf79a 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -4,11 +4,13 @@ import time import uuid from pathlib import Path +from typing import NamedTuple import pytest from deprecated import deprecated from commitizen import cmd, exceptions, git +from commitizen.version_schemes import Increment, Prerelease skip_below_py_3_10 = pytest.mark.skipif( sys.version_info < (3, 10), @@ -21,6 +23,14 @@ ) +class VersionSchemeTestArgs(NamedTuple): + current_version: str + increment: Increment | None + prerelease: Prerelease | None + prerelease_offset: int + devrelease: int | None + + class FakeCommand: def __init__(self, out=None, err=None, return_code=0): self.out = out From 42acbe48309939d6691b7559190bc42178f5edf4 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Sat, 20 Sep 2025 01:29:52 +0800 Subject: [PATCH 16/33] refactor: remove self.encoding for better maintainability --- commitizen/changelog_formats/base.py | 5 +++-- commitizen/commands/bump.py | 3 +-- commitizen/commands/changelog.py | 9 ++++++--- commitizen/commands/check.py | 5 +++-- commitizen/commands/commit.py | 13 +++++++++---- commitizen/commands/init.py | 9 ++++++--- 6 files changed, 28 insertions(+), 16 deletions(-) diff --git a/commitizen/changelog_formats/base.py b/commitizen/changelog_formats/base.py index 0a76ad68f..64a795207 100644 --- a/commitizen/changelog_formats/base.py +++ b/commitizen/changelog_formats/base.py @@ -24,7 +24,6 @@ def __init__(self, config: BaseConfig) -> None: # Constructor needs to be redefined because `Protocol` prevent instantiation by default # See: https://bugs.python.org/issue44807 self.config = config - self.encoding = self.config.settings["encoding"] self.tag_rules = TagRules( scheme=get_version_scheme(self.config.settings), tag_format=self.config.settings["tag_format"], @@ -36,7 +35,9 @@ def get_metadata(self, filepath: str) -> Metadata: if not os.path.isfile(filepath): return Metadata() - with open(filepath, encoding=self.encoding) as changelog_file: + with open( + filepath, encoding=self.config.settings["encoding"] + ) as changelog_file: return self.get_metadata_from_file(changelog_file) def get_metadata_from_file(self, file: IO[Any]) -> Metadata: diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index fc9649c6c..3dc678920 100644 --- a/commitizen/commands/bump.py +++ b/commitizen/commands/bump.py @@ -67,7 +67,6 @@ def __init__(self, config: BaseConfig, arguments: BumpArgs) -> None: raise NotAGitProjectError() self.config: BaseConfig = config - self.encoding = config.settings["encoding"] self.arguments = arguments self.bump_settings = cast( BumpArgs, @@ -335,7 +334,7 @@ def __call__(self) -> None: str(new_version), self.bump_settings["version_files"], check_consistency=self.check_consistency, - encoding=self.encoding, + encoding=self.config.settings["encoding"], ) ) diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py index c739419b8..27b8ccb25 100644 --- a/commitizen/commands/changelog.py +++ b/commitizen/commands/changelog.py @@ -67,7 +67,6 @@ def __init__(self, config: BaseConfig, arguments: ChangelogArgs) -> None: else changelog_file_name ) - self.encoding = self.config.settings["encoding"] self.cz = factory.committer_factory(self.config) self.start_rev = arguments.get("start_rev") or self.config.settings.get( @@ -157,7 +156,9 @@ def _find_incremental_rev(self, latest_version: str, tags: Iterable[GitTag]) -> def _write_changelog( self, changelog_out: str, lines: list[str], changelog_meta: changelog.Metadata ) -> None: - with smart_open(self.file_name, "w", encoding=self.encoding) as changelog_file: + with smart_open( + self.file_name, "w", encoding=self.config.settings["encoding"] + ) as changelog_file: partial_changelog: str | None = None if self.incremental: new_lines = changelog.incremental_build( @@ -259,7 +260,9 @@ def __call__(self) -> None: lines = [] if self.incremental and os.path.isfile(self.file_name): - with open(self.file_name, encoding=self.encoding) as changelog_file: + with open( + self.file_name, encoding=self.config.settings["encoding"] + ) as changelog_file: lines = changelog_file.readlines() self._write_changelog(changelog_out, lines, changelog_meta) diff --git a/commitizen/commands/check.py b/commitizen/commands/check.py index b79e6609e..d45e388f9 100644 --- a/commitizen/commands/check.py +++ b/commitizen/commands/check.py @@ -71,7 +71,6 @@ def __init__(self, config: BaseConfig, arguments: CheckArgs, *args: object) -> N self.commit_msg = sys.stdin.read() self.config: BaseConfig = config - self.encoding = config.settings["encoding"] self.cz = factory.committer_factory(self.config) def __call__(self) -> None: @@ -106,7 +105,9 @@ def _get_commit_message(self) -> str | None: # Get commit message from command line (--message) return self.commit_msg - with open(self.commit_msg_file, encoding=self.encoding) as commit_file: + with open( + self.commit_msg_file, encoding=self.config.settings["encoding"] + ) as commit_file: # Get commit message from file (--commit-msg-file) return commit_file.read() diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 0a6349303..8ab8d2cac 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -48,7 +48,6 @@ def __init__(self, config: BaseConfig, arguments: CommitArgs) -> None: raise NotAGitProjectError() self.config: BaseConfig = config - self.encoding = config.settings["encoding"] self.cz = factory.committer_factory(self.config) self.arguments = arguments self.backup_file_path = get_backup_file_path() @@ -59,7 +58,9 @@ def _read_backup_message(self) -> str | None: return None # Read commit message from backup - with open(self.backup_file_path, encoding=self.encoding) as f: + with open( + self.backup_file_path, encoding=self.config.settings["encoding"] + ) as f: return f.read().strip() def _prompt_commit_questions(self) -> str: @@ -146,7 +147,9 @@ def __call__(self) -> None: out.info(f"\n{commit_message}\n") if write_message_to_file: - with smart_open(write_message_to_file, "w", encoding=self.encoding) as file: + with smart_open( + write_message_to_file, "w", encoding=self.config.settings["encoding"] + ) as file: file.write(commit_message) if dry_run: @@ -160,7 +163,9 @@ def __call__(self) -> None: out.error(c.err) # Create commit backup - with smart_open(self.backup_file_path, "w", encoding=self.encoding) as f: + with smart_open( + self.backup_file_path, "w", encoding=self.config.settings["encoding"] + ) as f: f.write(commit_message) raise CommitError() diff --git a/commitizen/commands/init.py b/commitizen/commands/init.py index 92e7d06d7..35e9c2041 100644 --- a/commitizen/commands/init.py +++ b/commitizen/commands/init.py @@ -121,7 +121,6 @@ class Init: def __init__(self, config: BaseConfig, *args: object) -> None: self.config: BaseConfig = config - self.encoding = config.settings["encoding"] self.cz = factory.committer_factory(self.config) self.project_info = ProjectInfo() @@ -163,7 +162,9 @@ def __call__(self) -> None: if hook_types: config_data = self._get_config_data() with smart_open( - self._PRE_COMMIT_CONFIG_PATH, "w", encoding=self.encoding + self._PRE_COMMIT_CONFIG_PATH, + "w", + encoding=self.config.settings["encoding"], ) as config_file: yaml.safe_dump(config_data, stream=config_file) @@ -355,7 +356,9 @@ def _get_config_data(self) -> dict[str, Any]: # .pre-commit-config.yaml does not exist return {"repos": [CZ_HOOK_CONFIG]} - with open(self._PRE_COMMIT_CONFIG_PATH, encoding=self.encoding) as config_file: + with open( + self._PRE_COMMIT_CONFIG_PATH, encoding=self.config.settings["encoding"] + ) as config_file: config_data: dict[str, Any] = yaml.safe_load(config_file) or {} if not isinstance(repos := config_data.get("repos"), list): From 3ff4e55164f17c3ab9616f44542569790fceda37 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Sun, 14 Sep 2025 23:38:03 +0800 Subject: [PATCH 17/33] refactor(BaseConfig): update docstring, extract factory method and remove unnecessary variable assignment --- commitizen/commands/init.py | 71 ++++++++++++++++++----------- commitizen/config/__init__.py | 58 ++++++++++------------- commitizen/config/base_config.py | 8 +++- commitizen/config/factory.py | 22 +++++++++ commitizen/config/json_config.py | 9 ++-- commitizen/config/toml_config.py | 9 ++-- commitizen/config/yaml_config.py | 11 +++-- tests/commands/conftest.py | 3 +- tests/commands/test_init_command.py | 3 +- tests/test_conf.py | 21 +++++---- tests/test_cz_customize.py | 27 ++++++----- tests/test_cz_search_filter.py | 2 +- 12 files changed, 146 insertions(+), 98 deletions(-) create mode 100644 commitizen/config/factory.py diff --git a/commitizen/commands/init.py b/commitizen/commands/init.py index 35e9c2041..637caa284 100644 --- a/commitizen/commands/init.py +++ b/commitizen/commands/init.py @@ -2,6 +2,7 @@ import os import shutil +from pathlib import Path from typing import Any, NamedTuple import questionary @@ -9,7 +10,10 @@ from commitizen import cmd, factory, out from commitizen.__version__ import __version__ -from commitizen.config import BaseConfig, JsonConfig, TomlConfig, YAMLConfig +from commitizen.config import ( + BaseConfig, +) +from commitizen.config.factory import create_config from commitizen.cz import registry from commitizen.defaults import CONFIG_FILES, DEFAULT_SETTINGS from commitizen.exceptions import InitFailedError, NoAnswersError @@ -188,45 +192,33 @@ def __call__(self) -> None: ) out.write("commitizen pre-commit hook is now installed in your '.git'\n") - # Initialize configuration - if "toml" in config_path: - self.config = TomlConfig(data="", path=config_path) - elif "json" in config_path: - self.config = JsonConfig(data="{}", path=config_path) - elif "yaml" in config_path: - self.config = YAMLConfig(data="", path=config_path) - - # Create and initialize config - self.config.init_empty_config_content() - - self.config.set_key("name", cz_name) - self.config.set_key("tag_format", tag_format) - self.config.set_key("version_scheme", version_scheme) - if version_provider == "commitizen": - self.config.set_key("version", version.public) - else: - self.config.set_key("version_provider", version_provider) - if update_changelog_on_bump: - self.config.set_key("update_changelog_on_bump", update_changelog_on_bump) - if major_version_zero: - self.config.set_key("major_version_zero", major_version_zero) + _write_config_to_file( + path=config_path, + cz_name=cz_name, + version_provider=version_provider, + version_scheme=version_scheme, + version=version, + tag_format=tag_format, + update_changelog_on_bump=update_changelog_on_bump, + major_version_zero=major_version_zero, + ) out.write("\nYou can bump the version running:\n") out.info("\tcz bump\n") out.success("Configuration complete 🚀") - def _ask_config_path(self) -> str: + def _ask_config_path(self) -> Path: default_path = ( "pyproject.toml" if self.project_info.has_pyproject else ".cz.toml" ) - name: str = questionary.select( + filename: str = questionary.select( "Please choose a supported config file: ", choices=CONFIG_FILES, default=default_path, style=self.cz.style, ).unsafe_ask() - return name + return Path(filename) def _ask_name(self) -> str: name: str = questionary.select( @@ -372,3 +364,30 @@ def _get_config_data(self) -> dict[str, Any]: else: repos.append(CZ_HOOK_CONFIG) return config_data + + +def _write_config_to_file( + *, + path: Path, + cz_name: str, + version_provider: str, + version_scheme: str, + version: Version, + tag_format: str, + update_changelog_on_bump: bool, + major_version_zero: bool, +) -> None: + out_config = create_config(path=path) + out_config.init_empty_config_content() + + out_config.set_key("name", cz_name) + out_config.set_key("tag_format", tag_format) + out_config.set_key("version_scheme", version_scheme) + if version_provider == "commitizen": + out_config.set_key("version", version.public) + else: + out_config.set_key("version_provider", version_provider) + if update_changelog_on_bump: + out_config.set_key("update_changelog_on_bump", update_changelog_on_bump) + if major_version_zero: + out_config.set_key("major_version_zero", major_version_zero) diff --git a/commitizen/config/__init__.py b/commitizen/config/__init__.py index 9dfd591c4..6cf8b840d 100644 --- a/commitizen/config/__init__.py +++ b/commitizen/config/__init__.py @@ -1,58 +1,46 @@ from __future__ import annotations +from collections.abc import Generator from pathlib import Path from commitizen import defaults, git +from commitizen.config.factory import create_config from commitizen.exceptions import ConfigFileIsEmpty, ConfigFileNotFound from .base_config import BaseConfig -from .json_config import JsonConfig -from .toml_config import TomlConfig -from .yaml_config import YAMLConfig -def read_cfg(filepath: str | None = None) -> BaseConfig: - conf = BaseConfig() - +def _resolve_config_paths(filepath: str | None = None) -> Generator[Path, None, None]: if filepath is not None: - if not Path(filepath).exists(): + out_path = Path(filepath) + if not out_path.exists(): raise ConfigFileNotFound() - cfg_paths = (path for path in (Path(filepath),)) - else: - git_project_root = git.find_git_project_root() - cfg_search_paths = [Path(".")] - if git_project_root: - cfg_search_paths.append(git_project_root) + yield out_path + return - cfg_paths = ( - path / Path(filename) - for path in cfg_search_paths - for filename in defaults.CONFIG_FILES - ) + git_project_root = git.find_git_project_root() + cfg_search_paths = [Path(".")] + if git_project_root: + cfg_search_paths.append(git_project_root) - for filename in cfg_paths: - if not filename.exists(): - continue + for path in cfg_search_paths: + for filename in defaults.CONFIG_FILES: + out_path = path / Path(filename) + if out_path.exists(): + yield out_path - _conf: TomlConfig | JsonConfig | YAMLConfig +def read_cfg(filepath: str | None = None) -> BaseConfig: + for filename in _resolve_config_paths(filepath): with open(filename, "rb") as f: data: bytes = f.read() - if "toml" in filename.suffix: - _conf = TomlConfig(data=data, path=filename) - elif "json" in filename.suffix: - _conf = JsonConfig(data=data, path=filename) - elif "yaml" in filename.suffix: - _conf = YAMLConfig(data=data, path=filename) + conf = create_config(data=data, path=filename) + if not conf.is_empty_config: + return conf - if filepath is not None and _conf.is_empty_config: + if filepath is not None: raise ConfigFileIsEmpty() - elif _conf.is_empty_config: - continue - else: - conf = _conf - break - return conf + return BaseConfig() diff --git a/commitizen/config/base_config.py b/commitizen/config/base_config.py index 4b8f5f05f..f7a6f3b8e 100644 --- a/commitizen/config/base_config.py +++ b/commitizen/config/base_config.py @@ -17,8 +17,8 @@ class BaseConfig: def __init__(self) -> None: + self.is_empty_config = False self._settings: Settings = DEFAULT_SETTINGS.copy() - self.encoding = self.settings["encoding"] self._path: Path | None = None @property @@ -30,7 +30,7 @@ def path(self) -> Path: return self._path # type: ignore[return-value] @path.setter - def path(self, path: str | Path) -> None: + def path(self, path: Path) -> None: self._path = Path(path) def set_key(self, key: str, value: Any) -> Self: @@ -48,4 +48,8 @@ def _parse_setting(self, data: bytes | str) -> None: raise NotImplementedError() def init_empty_config_content(self) -> None: + """Create a config file with the empty config content. + + The implementation is different for each config file type. + """ raise NotImplementedError() diff --git a/commitizen/config/factory.py b/commitizen/config/factory.py new file mode 100644 index 000000000..b77aea5f3 --- /dev/null +++ b/commitizen/config/factory.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from pathlib import Path + +from commitizen.config.base_config import BaseConfig +from commitizen.config.json_config import JsonConfig +from commitizen.config.toml_config import TomlConfig +from commitizen.config.yaml_config import YAMLConfig + + +def create_config(*, data: bytes | str | None = None, path: Path) -> BaseConfig: + if "toml" in path.suffix: + return TomlConfig(data=data or "", path=path) + if "json" in path.suffix: + return JsonConfig(data=data or "{}", path=path) + if "yaml" in path.suffix: + return YAMLConfig(data=data or "", path=path) + + # Should be unreachable. See the constant CONFIG_FILES. + raise ValueError( + f"Unsupported config file: {path.name} due to unknown file extension" + ) diff --git a/commitizen/config/json_config.py b/commitizen/config/json_config.py index be1f1c36b..4655f48d5 100644 --- a/commitizen/config/json_config.py +++ b/commitizen/config/json_config.py @@ -20,14 +20,15 @@ class JsonConfig(BaseConfig): - def __init__(self, *, data: bytes | str, path: Path | str) -> None: + def __init__(self, *, data: bytes | str, path: Path) -> None: super().__init__() - self.is_empty_config = False self.path = path self._parse_setting(data) def init_empty_config_content(self) -> None: - with smart_open(self.path, "a", encoding=self.encoding) as json_file: + with smart_open( + self.path, "a", encoding=self._settings["encoding"] + ) as json_file: json.dump({"commitizen": {}}, json_file) def set_key(self, key: str, value: Any) -> Self: @@ -40,7 +41,7 @@ def set_key(self, key: str, value: Any) -> Self: parser = json.load(f) parser["commitizen"][key] = value - with smart_open(self.path, "w", encoding=self.encoding) as f: + with smart_open(self.path, "w", encoding=self._settings["encoding"]) as f: json.dump(parser, f, indent=2) return self diff --git a/commitizen/config/toml_config.py b/commitizen/config/toml_config.py index 2164d3f99..8fa3d9782 100644 --- a/commitizen/config/toml_config.py +++ b/commitizen/config/toml_config.py @@ -21,9 +21,8 @@ class TomlConfig(BaseConfig): - def __init__(self, *, data: bytes | str, path: Path | str) -> None: + def __init__(self, *, data: bytes | str, path: Path) -> None: super().__init__() - self.is_empty_config = False self.path = path self._parse_setting(data) @@ -38,7 +37,9 @@ def init_empty_config_content(self) -> None: if parser.get("tool") is None: parser["tool"] = table() parser["tool"]["commitizen"] = table() # type: ignore[index] - output_toml_file.write(parser.as_string().encode(self.encoding)) + output_toml_file.write( + parser.as_string().encode(self._settings["encoding"]) + ) def set_key(self, key: str, value: Any) -> Self: """Set or update a key in the conf. @@ -51,7 +52,7 @@ def set_key(self, key: str, value: Any) -> Self: parser["tool"]["commitizen"][key] = value # type: ignore[index] with open(self.path, "wb") as f: - f.write(parser.as_string().encode(self.encoding)) + f.write(parser.as_string().encode(self._settings["encoding"])) return self def _parse_setting(self, data: bytes | str) -> None: diff --git a/commitizen/config/yaml_config.py b/commitizen/config/yaml_config.py index f2a79e693..d2924ef02 100644 --- a/commitizen/config/yaml_config.py +++ b/commitizen/config/yaml_config.py @@ -21,14 +21,15 @@ class YAMLConfig(BaseConfig): - def __init__(self, *, data: bytes | str, path: Path | str) -> None: + def __init__(self, *, data: bytes | str, path: Path) -> None: super().__init__() - self.is_empty_config = False self.path = path self._parse_setting(data) def init_empty_config_content(self) -> None: - with smart_open(self.path, "a", encoding=self.encoding) as json_file: + with smart_open( + self.path, "a", encoding=self._settings["encoding"] + ) as json_file: yaml.dump({"commitizen": {}}, json_file, explicit_start=True) def _parse_setting(self, data: bytes | str) -> None: @@ -61,7 +62,9 @@ def set_key(self, key: str, value: Any) -> Self: parser = yaml.load(yaml_file, Loader=yaml.FullLoader) parser["commitizen"][key] = value - with smart_open(self.path, "w", encoding=self.encoding) as yaml_file: + with smart_open( + self.path, "w", encoding=self._settings["encoding"] + ) as yaml_file: yaml.dump(parser, yaml_file, explicit_start=True) return self diff --git a/tests/commands/conftest.py b/tests/commands/conftest.py index 91931849b..4f9b5de3c 100644 --- a/tests/commands/conftest.py +++ b/tests/commands/conftest.py @@ -3,7 +3,8 @@ import pytest from commitizen import defaults -from commitizen.config import BaseConfig, JsonConfig +from commitizen.config import BaseConfig +from commitizen.config.json_config import JsonConfig @pytest.fixture() diff --git a/tests/commands/test_init_command.py b/tests/commands/test_init_command.py index 8c632a2b6..4d8492ac3 100644 --- a/tests/commands/test_init_command.py +++ b/tests/commands/test_init_command.py @@ -3,6 +3,7 @@ import json import os import sys +from pathlib import Path from typing import Any import pytest @@ -87,7 +88,7 @@ def test_init_without_setup_pre_commit_hook( def test_init_when_config_already_exists(config: BaseConfig, capsys): # Set config path - path = os.sep.join(["tests", "pyproject.toml"]) + path = Path(os.sep.join(["tests", "pyproject.toml"])) config.path = path commands.Init(config)() diff --git a/tests/test_conf.py b/tests/test_conf.py index cc38bf4b0..0953401ea 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -9,6 +9,9 @@ import yaml from commitizen import config, defaults, git +from commitizen.config.json_config import JsonConfig +from commitizen.config.toml_config import TomlConfig +from commitizen.config.yaml_config import YAMLConfig from commitizen.exceptions import ConfigFileIsEmpty, InvalidConfigurationError PYPROJECT = """ @@ -211,7 +214,7 @@ def test_load_cz_json_not_from_config_argument(_, tmpdir): _not_root_path.write(JSON_STR) cfg = config.read_cfg(filepath="./not_in_root/.cz.json") - json_cfg_by_class = config.JsonConfig(data=JSON_STR, path=_not_root_path) + json_cfg_by_class = JsonConfig(data=JSON_STR, path=_not_root_path) assert cfg.settings == json_cfg_by_class.settings def test_load_cz_yaml_not_from_config_argument(_, tmpdir): @@ -220,7 +223,7 @@ def test_load_cz_yaml_not_from_config_argument(_, tmpdir): _not_root_path.write(YAML_STR) cfg = config.read_cfg(filepath="./not_in_root/.cz.yaml") - yaml_cfg_by_class = config.YAMLConfig(data=YAML_STR, path=_not_root_path) + yaml_cfg_by_class = YAMLConfig(data=YAML_STR, path=_not_root_path) assert cfg.settings == yaml_cfg_by_class._settings def test_load_empty_pyproject_toml_from_config_argument(_, tmpdir): @@ -244,7 +247,7 @@ def test_load_empty_pyproject_toml_from_config_argument(_, tmpdir): class TestTomlConfig: def test_init_empty_config_content(self, tmpdir, config_file, exception_string): path = tmpdir.mkdir("commitizen").join(config_file) - toml_config = config.TomlConfig(data="", path=path) + toml_config = TomlConfig(data="", path=path) toml_config.init_empty_config_content() with open(path, encoding="utf-8") as toml_file: @@ -257,7 +260,7 @@ def test_init_empty_config_content_with_existing_content( path = tmpdir.mkdir("commitizen").join(config_file) path.write(existing_content) - toml_config = config.TomlConfig(data="", path=path) + toml_config = TomlConfig(data="", path=path) toml_config.init_empty_config_content() with open(path, encoding="utf-8") as toml_file: @@ -270,7 +273,7 @@ def test_init_with_invalid_config_content( path = tmpdir.mkdir("commitizen").join(config_file) with pytest.raises(InvalidConfigurationError, match=exception_string): - config.TomlConfig(data=existing_content, path=path) + TomlConfig(data=existing_content, path=path) @pytest.mark.parametrize( @@ -284,7 +287,7 @@ def test_init_with_invalid_config_content( class TestJsonConfig: def test_init_empty_config_content(self, tmpdir, config_file, exception_string): path = tmpdir.mkdir("commitizen").join(config_file) - json_config = config.JsonConfig(data="{}", path=path) + json_config = JsonConfig(data="{}", path=path) json_config.init_empty_config_content() with open(path, encoding="utf-8") as json_file: @@ -297,7 +300,7 @@ def test_init_with_invalid_config_content( path = tmpdir.mkdir("commitizen").join(config_file) with pytest.raises(InvalidConfigurationError, match=exception_string): - config.JsonConfig(data=existing_content, path=path) + JsonConfig(data=existing_content, path=path) @pytest.mark.parametrize( @@ -311,7 +314,7 @@ def test_init_with_invalid_config_content( class TestYamlConfig: def test_init_empty_config_content(self, tmpdir, config_file, exception_string): path = tmpdir.mkdir("commitizen").join(config_file) - yaml_config = config.YAMLConfig(data="{}", path=path) + yaml_config = YAMLConfig(data="{}", path=path) yaml_config.init_empty_config_content() with open(path) as yaml_file: @@ -322,4 +325,4 @@ def test_init_with_invalid_content(self, tmpdir, config_file, exception_string): path = tmpdir.mkdir("commitizen").join(config_file) with pytest.raises(InvalidConfigurationError, match=exception_string): - config.YAMLConfig(data=existing_content, path=path) + YAMLConfig(data=existing_content, path=path) diff --git a/tests/test_cz_customize.py b/tests/test_cz_customize.py index 933b1aa06..76341828d 100644 --- a/tests/test_cz_customize.py +++ b/tests/test_cz_customize.py @@ -1,6 +1,11 @@ +from pathlib import Path + import pytest -from commitizen.config import BaseConfig, JsonConfig, TomlConfig, YAMLConfig +from commitizen.config import BaseConfig +from commitizen.config.json_config import JsonConfig +from commitizen.config.toml_config import TomlConfig +from commitizen.config.yaml_config import YAMLConfig from commitizen.cz.customize import CustomizeCommitsCz from commitizen.exceptions import MissingCzCustomizeConfigError @@ -319,8 +324,8 @@ @pytest.fixture( params=[ - TomlConfig(data=TOML_STR, path="not_exist.toml"), - JsonConfig(data=JSON_STR, path="not_exist.json"), + TomlConfig(data=TOML_STR, path=Path("not_exist.toml")), + JsonConfig(data=JSON_STR, path=Path("not_exist.json")), ] ) def config(request): @@ -334,9 +339,9 @@ def config(request): @pytest.fixture( params=[ - TomlConfig(data=TOML_STR_INFO_PATH, path="not_exist.toml"), - JsonConfig(data=JSON_STR_INFO_PATH, path="not_exist.json"), - YAMLConfig(data=YAML_STR_INFO_PATH, path="not_exist.yaml"), + TomlConfig(data=TOML_STR_INFO_PATH, path=Path("not_exist.toml")), + JsonConfig(data=JSON_STR_INFO_PATH, path=Path("not_exist.json")), + YAMLConfig(data=YAML_STR_INFO_PATH, path=Path("not_exist.yaml")), ] ) def config_info(request): @@ -345,9 +350,9 @@ def config_info(request): @pytest.fixture( params=[ - TomlConfig(data=TOML_STR_WITHOUT_INFO, path="not_exist.toml"), - JsonConfig(data=JSON_STR_WITHOUT_PATH, path="not_exist.json"), - YAMLConfig(data=YAML_STR_WITHOUT_PATH, path="not_exist.yaml"), + TomlConfig(data=TOML_STR_WITHOUT_INFO, path=Path("not_exist.toml")), + JsonConfig(data=JSON_STR_WITHOUT_PATH, path=Path("not_exist.json")), + YAMLConfig(data=YAML_STR_WITHOUT_PATH, path=Path("not_exist.yaml")), ] ) def config_without_info(request): @@ -356,8 +361,8 @@ def config_without_info(request): @pytest.fixture( params=[ - TomlConfig(data=TOML_WITH_UNICODE, path="not_exist.toml"), - JsonConfig(data=JSON_WITH_UNICODE, path="not_exist.json"), + TomlConfig(data=TOML_WITH_UNICODE, path=Path("not_exist.toml")), + JsonConfig(data=JSON_WITH_UNICODE, path=Path("not_exist.json")), ] ) def config_with_unicode(request): diff --git a/tests/test_cz_search_filter.py b/tests/test_cz_search_filter.py index 0e70e3104..cbceb8b88 100644 --- a/tests/test_cz_search_filter.py +++ b/tests/test_cz_search_filter.py @@ -1,6 +1,6 @@ import pytest -from commitizen.config import TomlConfig +from commitizen.config.toml_config import TomlConfig from commitizen.cz.customize import CustomizeCommitsCz TOML_WITH_SEARCH_FILTER = r""" From 6be61973e2d7459317ae1e2f599cecd821533608 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Mon, 15 Sep 2025 14:05:23 +0800 Subject: [PATCH 18/33] refactor(Init): make project_info a module and remove self.project_info --- commitizen/commands/init.py | 91 ++--------------------------- commitizen/project_info.py | 47 +++++++++++++++ tests/commands/test_init_command.py | 4 +- tests/test_project_info.py | 90 ++++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 87 deletions(-) create mode 100644 commitizen/project_info.py create mode 100644 tests/test_project_info.py diff --git a/commitizen/commands/init.py b/commitizen/commands/init.py index 637caa284..4a8a69d9e 100644 --- a/commitizen/commands/init.py +++ b/commitizen/commands/init.py @@ -1,14 +1,12 @@ from __future__ import annotations -import os -import shutil from pathlib import Path from typing import Any, NamedTuple import questionary import yaml -from commitizen import cmd, factory, out +from commitizen import cmd, factory, out, project_info from commitizen.__version__ import __version__ from commitizen.config import ( BaseConfig, @@ -69,64 +67,12 @@ def title(self) -> str: ) -class ProjectInfo: - """Discover information about the current folder.""" - - @property - def has_pyproject(self) -> bool: - return os.path.isfile("pyproject.toml") - - @property - def has_uv_lock(self) -> bool: - return os.path.isfile("uv.lock") - - @property - def has_setup(self) -> bool: - return os.path.isfile("setup.py") - - @property - def has_pre_commit_config(self) -> bool: - return os.path.isfile(".pre-commit-config.yaml") - - @property - def is_python_uv(self) -> bool: - return self.has_pyproject and self.has_uv_lock - - @property - def is_python_poetry(self) -> bool: - if not self.has_pyproject: - return False - with open("pyproject.toml") as f: - return "[tool.poetry]" in f.read() - - @property - def is_python(self) -> bool: - return self.has_pyproject or self.has_setup - - @property - def is_rust_cargo(self) -> bool: - return os.path.isfile("Cargo.toml") - - @property - def is_npm_package(self) -> bool: - return os.path.isfile("package.json") - - @property - def is_php_composer(self) -> bool: - return os.path.isfile("composer.json") - - @property - def is_pre_commit_installed(self) -> bool: - return bool(shutil.which("pre-commit")) - - class Init: _PRE_COMMIT_CONFIG_PATH = ".pre-commit-config.yaml" def __init__(self, config: BaseConfig, *args: object) -> None: self.config: BaseConfig = config self.cz = factory.committer_factory(self.config) - self.project_info = ProjectInfo() def __call__(self) -> None: if self.config.path: @@ -172,7 +118,7 @@ def __call__(self) -> None: ) as config_file: yaml.safe_dump(config_data, stream=config_file) - if not self.project_info.is_pre_commit_installed: + if not project_info.is_pre_commit_installed(): raise InitFailedError( "Failed to install pre-commit hook.\n" "pre-commit is not installed in current environment." @@ -208,14 +154,10 @@ def __call__(self) -> None: out.success("Configuration complete 🚀") def _ask_config_path(self) -> Path: - default_path = ( - "pyproject.toml" if self.project_info.has_pyproject else ".cz.toml" - ) - filename: str = questionary.select( "Please choose a supported config file: ", choices=CONFIG_FILES, - default=default_path, + default=project_info.get_default_config_filename(), style=self.cz.style, ).unsafe_ask() return Path(filename) @@ -280,37 +222,17 @@ def _ask_version_provider(self) -> str: "Choose the source of the version:", choices=_VERSION_PROVIDER_CHOICES, style=self.cz.style, - default=self._default_version_provider, + default=project_info.get_default_version_provider(), ).unsafe_ask() return version_provider - @property - def _default_version_provider(self) -> str: - if self.project_info.is_python: - if self.project_info.is_python_poetry: - return "poetry" - if self.project_info.is_python_uv: - return "uv" - return "pep621" - - if self.project_info.is_rust_cargo: - return "cargo" - if self.project_info.is_npm_package: - return "npm" - if self.project_info.is_php_composer: - return "composer" - - return "commitizen" - def _ask_version_scheme(self) -> str: """Ask for setting: version_scheme""" - default_scheme = "pep440" if self.project_info.is_python else "semver" - scheme: str = questionary.select( "Choose version scheme: ", choices=KNOWN_SCHEMES, style=self.cz.style, - default=default_scheme, + default=project_info.get_default_version_scheme(), ).unsafe_ask() return scheme @@ -344,8 +266,7 @@ def _get_config_data(self) -> dict[str, Any]: ], } - if not self.project_info.has_pre_commit_config: - # .pre-commit-config.yaml does not exist + if not Path(".pre-commit-config.yaml").is_file(): return {"repos": [CZ_HOOK_CONFIG]} with open( diff --git a/commitizen/project_info.py b/commitizen/project_info.py new file mode 100644 index 000000000..a75438800 --- /dev/null +++ b/commitizen/project_info.py @@ -0,0 +1,47 @@ +"""Resolves project information about the current working directory.""" + +import shutil +from pathlib import Path +from typing import Literal + + +def is_pre_commit_installed() -> bool: + return bool(shutil.which("pre-commit")) + + +def get_default_version_provider() -> Literal[ + "commitizen", "cargo", "composer", "npm", "pep621", "poetry", "uv" +]: + pyproject_path = Path("pyproject.toml") + if pyproject_path.is_file(): + if "[tool.poetry]" in pyproject_path.read_text(): + return "poetry" + if Path("uv.lock").is_file(): + return "uv" + return "pep621" + + if Path("setup.py").is_file(): + return "pep621" + + if Path("Cargo.toml").is_file(): + return "cargo" + + if Path("package.json").is_file(): + return "npm" + + if Path("composer.json").is_file(): + return "composer" + + return "commitizen" + + +def get_default_config_filename() -> Literal["pyproject.toml", ".cz.toml"]: + return "pyproject.toml" if Path("pyproject.toml").is_file() else ".cz.toml" + + +def get_default_version_scheme() -> Literal["pep440", "semver"]: + return ( + "pep440" + if Path("pyproject.toml").is_file() or Path("setup.py").is_file() + else "semver" + ) diff --git a/tests/commands/test_init_command.py b/tests/commands/test_init_command.py index 4d8492ac3..cfecfebeb 100644 --- a/tests/commands/test_init_command.py +++ b/tests/commands/test_init_command.py @@ -122,7 +122,7 @@ def test_init_without_choosing_tag(config: BaseConfig, mocker: MockFixture, tmpd def pre_commit_installed(mocker: MockFixture): # Assume the `pre-commit` is installed mocker.patch( - "commitizen.commands.init.ProjectInfo.is_pre_commit_installed", + "commitizen.project_info.is_pre_commit_installed", return_value=True, ) # And installation success (i.e. no exception raised) @@ -232,7 +232,7 @@ def test_pre_commit_not_installed( ): # Assume `pre-commit` is not installed mocker.patch( - "commitizen.commands.init.ProjectInfo.is_pre_commit_installed", + "commitizen.project_info.is_pre_commit_installed", return_value=False, ) with tmpdir.as_cwd(): diff --git a/tests/test_project_info.py b/tests/test_project_info.py new file mode 100644 index 000000000..d30a743e5 --- /dev/null +++ b/tests/test_project_info.py @@ -0,0 +1,90 @@ +"""Tests for project_info module.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from commitizen import project_info + + +def _create_project_files(files: dict[str, str | None]) -> None: + for file_path, content in files.items(): + path = Path(file_path) + if content is None: + path.touch() + else: + path.write_text(content) + + +@pytest.mark.parametrize( + "which_return, expected", + [ + ("/usr/local/bin/pre-commit", True), + (None, False), + ("", False), + ], +) +def test_is_pre_commit_installed(mocker, which_return, expected): + mocker.patch("shutil.which", return_value=which_return) + assert project_info.is_pre_commit_installed() is expected + + +@pytest.mark.parametrize( + "files, expected", + [ + ( + {"pyproject.toml": '[tool.poetry]\nname = "test"\nversion = "0.1.0"'}, + "poetry", + ), + ({"pyproject.toml": "", "uv.lock": ""}, "uv"), + ( + {"pyproject.toml": '[tool.commitizen]\nversion = "0.1.0"'}, + "pep621", + ), + ({"setup.py": ""}, "pep621"), + ({"Cargo.toml": ""}, "cargo"), + ({"package.json": ""}, "npm"), + ({"composer.json": ""}, "composer"), + ({}, "commitizen"), + ( + { + "pyproject.toml": "", + "Cargo.toml": "", + "package.json": "", + "composer.json": "", + }, + "pep621", + ), + ], +) +def test_get_default_version_provider(chdir, files, expected): + _create_project_files(files) + assert project_info.get_default_version_provider() == expected + + +@pytest.mark.parametrize( + "files, expected", + [ + ({"pyproject.toml": ""}, "pyproject.toml"), + ({}, ".cz.toml"), + ], +) +def test_get_default_config_filename(chdir, files, expected): + _create_project_files(files) + assert project_info.get_default_config_filename() == expected + + +@pytest.mark.parametrize( + "files, expected", + [ + ({"pyproject.toml": ""}, "pep440"), + ({"setup.py": ""}, "pep440"), + ({"package.json": ""}, "semver"), + ({}, "semver"), + ], +) +def test_get_default_version_scheme(chdir, files, expected): + _create_project_files(files) + assert project_info.get_default_version_scheme() == expected From df5a48e5c8e1f4cd7c700ec972de8d45544a84ab Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Mon, 29 Sep 2025 11:15:54 +0800 Subject: [PATCH 19/33] refactor(hooks): refactor to improve readability --- hooks/prepare-commit-msg.py | 60 +++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/hooks/prepare-commit-msg.py b/hooks/prepare-commit-msg.py index a100f13af..017ecc28e 100755 --- a/hooks/prepare-commit-msg.py +++ b/hooks/prepare-commit-msg.py @@ -2,7 +2,7 @@ import shutil import subprocess import sys -from subprocess import CalledProcessError +from pathlib import Path try: from commitizen.cz.utils import get_backup_file_path @@ -23,33 +23,34 @@ def prepare_commit_msg(commit_msg_file: str) -> int: ], capture_output=True, ).returncode - if exit_code != 0: - backup_file_path = get_backup_file_path() - if backup_file_path.is_file(): - # confirm if commit message from backup file should be reused - answer = input("retry with previous message? [y/N]: ") - if answer.lower() == "y": - shutil.copyfile(backup_file_path, commit_msg_file) - return 0 - - # use commitizen to generate the commit message - try: - subprocess.run( - [ - "cz", - "commit", - "--dry-run", - "--write-message-to-file", - commit_msg_file, - ], - stdin=sys.stdin, - stdout=sys.stdout, - ).check_returncode() - except CalledProcessError as error: - return error.returncode - - # write message to backup file - shutil.copyfile(commit_msg_file, backup_file_path) + if exit_code == 0: + return 0 + + backup_file = Path(get_backup_file_path()) + if backup_file.is_file(): + # confirm if commit message from backup file should be reused + answer = input("retry with previous message? [y/N]: ") + if answer.lower() == "y": + shutil.copyfile(backup_file, commit_msg_file) + return 0 + + # use commitizen to generate the commit message + exit_code = subprocess.run( + [ + "cz", + "commit", + "--dry-run", + "--write-message-to-file", + commit_msg_file, + ], + stdin=sys.stdin, + stdout=sys.stdout, + ).returncode + if exit_code: + return exit_code + + # write message to backup file + shutil.copyfile(commit_msg_file, backup_file) return 0 @@ -57,4 +58,5 @@ def prepare_commit_msg(commit_msg_file: str) -> int: # make hook interactive by attaching /dev/tty to stdin with open("/dev/tty") as tty: sys.stdin = tty - exit(prepare_commit_msg(sys.argv[1])) + exit_code = prepare_commit_msg(sys.argv[1]) + exit(exit_code) From f2c89b54ae5171feffd3cd4a1f7932a81d19c645 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Mon, 29 Sep 2025 02:25:08 +0800 Subject: [PATCH 20/33] refactor(Commit): refactor _prompt_commit_questions and fix some type hint --- commitizen/commands/commit.py | 38 ++++++++++++++++++++--------------- commitizen/cz/base.py | 2 +- tests/conftest.py | 2 +- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 8ab8d2cac..5a37d4d7c 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -63,33 +63,37 @@ def _read_backup_message(self) -> str | None: ) as f: return f.read().strip() - def _prompt_commit_questions(self) -> str: + def _get_message_by_prompt_commit_questions(self) -> str: # Prompt user for the commit message - cz = self.cz - questions = cz.questions() + questions = self.cz.questions() for question in (q for q in questions if q["type"] == "list"): question["use_shortcuts"] = self.config.settings["use_shortcuts"] try: - answers = questionary.prompt(questions, style=cz.style) + answers = questionary.prompt(questions, style=self.cz.style) except ValueError as err: root_err = err.__context__ if isinstance(root_err, CzException): - raise CustomError(root_err.__str__()) + raise CustomError(str(root_err)) raise err if not answers: raise NoAnswersError() - message = cz.message(answers) - message_len = len(message.partition("\n")[0].strip()) - message_length_limit = self.arguments.get("message_length_limit", 0) - if 0 < message_length_limit < message_len: + message = self.cz.message(answers) + self._validate_subject_length(message) + return message + + def _validate_subject_length(self, message: str) -> None: + # By the contract, message_length_limit is set to 0 for no limit + subject = message.partition("\n")[0].strip() + limit = self.arguments.get("message_length_limit", 0) + if limit == 0: + return + if len(subject) > limit: raise CommitMessageLengthExceededError( - f"Length of commit message exceeds limit ({message_len}/{message_length_limit})" + f"Length of commit message exceeds limit ({len(subject)}/{limit}), subject: '{subject}'" ) - return message - def manual_edit(self, message: str) -> str: editor = git.get_core_editor() if editor is None: @@ -114,11 +118,13 @@ def _get_message(self) -> str: raise NoCommitBackupError() return commit_message - if self.config.settings.get("retry_after_failure") and not self.arguments.get( - "no_retry" + if ( + self.config.settings.get("retry_after_failure") + and not self.arguments.get("no_retry") + and (backup_message := self._read_backup_message()) ): - return self._read_backup_message() or self._prompt_commit_questions() - return self._prompt_commit_questions() + return backup_message + return self._get_message_by_prompt_commit_questions() def __call__(self) -> None: extra_args = self.arguments.get("extra_cli_args", "") diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py index ecb1a2196..8466b58bb 100644 --- a/commitizen/cz/base.py +++ b/commitizen/cz/base.py @@ -68,7 +68,7 @@ def __init__(self, config: BaseConfig) -> None: self.config.settings.update({"style": BaseCommitizen.default_style_config}) @abstractmethod - def questions(self) -> Iterable[CzQuestion]: + def questions(self) -> list[CzQuestion]: """Questions regarding the commit message.""" @abstractmethod diff --git a/tests/conftest.py b/tests/conftest.py index 61b64ae8d..04a448d99 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -177,7 +177,7 @@ class SemverCommitizen(BaseCommitizen): "patch": "Bugs", } - def questions(self) -> list: + def questions(self) -> list[CzQuestion]: return [ { "type": "list", From c4902b68e5b53c47a37bebb1c67885b917db01f2 Mon Sep 17 00:00:00 2001 From: Wei Lee Date: Sat, 8 Nov 2025 16:36:39 +0800 Subject: [PATCH 21/33] fixup! refactor(Commit): refactor _prompt_commit_questions and fix some type hint --- commitizen/commands/commit.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 5a37d4d7c..d16b4d8d8 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -80,18 +80,16 @@ def _get_message_by_prompt_commit_questions(self) -> str: raise NoAnswersError() message = self.cz.message(answers) - self._validate_subject_length(message) + if limit := self.arguments.get("message_length_limit", 0): + self._validate_subject_length(message=message, length_limit=limit) return message - def _validate_subject_length(self, message: str) -> None: + def _validate_subject_length(self, *, message: str, length_limit: int) -> None: # By the contract, message_length_limit is set to 0 for no limit subject = message.partition("\n")[0].strip() - limit = self.arguments.get("message_length_limit", 0) - if limit == 0: - return - if len(subject) > limit: + if len(subject) > length_limit: raise CommitMessageLengthExceededError( - f"Length of commit message exceeds limit ({len(subject)}/{limit}), subject: '{subject}'" + f"Length of commit message exceeds limit ({len(subject)}/{length_limit}), subject: '{subject}'" ) def manual_edit(self, message: str) -> str: From 4cda767e7f4f6e87386aaeaa8fa96ea806134e7e Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Sat, 13 Sep 2025 21:38:24 +0800 Subject: [PATCH 22/33] refactor(TomlConfig): minor cleanups for DX --- commitizen/config/toml_config.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/commitizen/config/toml_config.py b/commitizen/config/toml_config.py index 8fa3d9782..0aadad8c1 100644 --- a/commitizen/config/toml_config.py +++ b/commitizen/config/toml_config.py @@ -2,9 +2,9 @@ import os from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING -from tomlkit import exceptions, parse, table +from tomlkit import TOMLDocument, exceptions, parse, table from commitizen.exceptions import InvalidConfigurationError @@ -27,32 +27,33 @@ def __init__(self, *, data: bytes | str, path: Path) -> None: self._parse_setting(data) def init_empty_config_content(self) -> None: + config_doc = TOMLDocument() if os.path.isfile(self.path): with open(self.path, "rb") as input_toml_file: - parser = parse(input_toml_file.read()) - else: - parser = parse("") + config_doc = parse(input_toml_file.read()) + + if config_doc.get("tool") is None: + config_doc["tool"] = table() + config_doc["tool"]["commitizen"] = table() # type: ignore[index] with open(self.path, "wb") as output_toml_file: - if parser.get("tool") is None: - parser["tool"] = table() - parser["tool"]["commitizen"] = table() # type: ignore[index] output_toml_file.write( - parser.as_string().encode(self._settings["encoding"]) + config_doc.as_string().encode(self._settings["encoding"]) ) - def set_key(self, key: str, value: Any) -> Self: + def set_key(self, key: str, value: object) -> Self: """Set or update a key in the conf. For now only strings are supported. We use to update the version number. """ with open(self.path, "rb") as f: - parser = parse(f.read()) + config_doc = parse(f.read()) - parser["tool"]["commitizen"][key] = value # type: ignore[index] + config_doc["tool"]["commitizen"][key] = value # type: ignore[index] with open(self.path, "wb") as f: - f.write(parser.as_string().encode(self._settings["encoding"])) + f.write(config_doc.as_string().encode(self._settings["encoding"])) + return self def _parse_setting(self, data: bytes | str) -> None: From 2fc95b18f305c2adeabcb6dee2a0c39624c576f0 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Sun, 14 Sep 2025 22:15:36 +0800 Subject: [PATCH 23/33] style(BaseConfig): update set_key comments and type annotation, remove duplicated docstring --- commitizen/config/base_config.py | 9 ++++----- commitizen/config/json_config.py | 15 +++++---------- commitizen/config/toml_config.py | 5 ----- commitizen/config/yaml_config.py | 15 +++++---------- 4 files changed, 14 insertions(+), 30 deletions(-) diff --git a/commitizen/config/base_config.py b/commitizen/config/base_config.py index f7a6f3b8e..98270915d 100644 --- a/commitizen/config/base_config.py +++ b/commitizen/config/base_config.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from commitizen.defaults import DEFAULT_SETTINGS, Settings @@ -33,11 +33,10 @@ def path(self) -> Path: def path(self, path: Path) -> None: self._path = Path(path) - def set_key(self, key: str, value: Any) -> Self: - """Set or update a key in the conf. + def set_key(self, key: str, value: object) -> Self: + """Set or update a key in the config file. - For now only strings are supported. - We use to update the version number. + Currently, only strings are supported for the parameter key. """ raise NotImplementedError() diff --git a/commitizen/config/json_config.py b/commitizen/config/json_config.py index 4655f48d5..4e7097aa1 100644 --- a/commitizen/config/json_config.py +++ b/commitizen/config/json_config.py @@ -2,7 +2,7 @@ import json from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from commitizen.exceptions import InvalidConfigurationError from commitizen.git import smart_open @@ -31,18 +31,13 @@ def init_empty_config_content(self) -> None: ) as json_file: json.dump({"commitizen": {}}, json_file) - def set_key(self, key: str, value: Any) -> Self: - """Set or update a key in the conf. - - For now only strings are supported. - We use to update the version number. - """ + def set_key(self, key: str, value: object) -> Self: with open(self.path, "rb") as f: - parser = json.load(f) + config_doc = json.load(f) - parser["commitizen"][key] = value + config_doc["commitizen"][key] = value with smart_open(self.path, "w", encoding=self._settings["encoding"]) as f: - json.dump(parser, f, indent=2) + json.dump(config_doc, f, indent=2) return self def _parse_setting(self, data: bytes | str) -> None: diff --git a/commitizen/config/toml_config.py b/commitizen/config/toml_config.py index 0aadad8c1..4ea1dca7d 100644 --- a/commitizen/config/toml_config.py +++ b/commitizen/config/toml_config.py @@ -42,11 +42,6 @@ def init_empty_config_content(self) -> None: ) def set_key(self, key: str, value: object) -> Self: - """Set or update a key in the conf. - - For now only strings are supported. - We use to update the version number. - """ with open(self.path, "rb") as f: config_doc = parse(f.read()) diff --git a/commitizen/config/yaml_config.py b/commitizen/config/yaml_config.py index d2924ef02..c048ab272 100644 --- a/commitizen/config/yaml_config.py +++ b/commitizen/config/yaml_config.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import yaml @@ -52,19 +52,14 @@ def _parse_setting(self, data: bytes | str) -> None: except (KeyError, TypeError): self.is_empty_config = True - def set_key(self, key: str, value: Any) -> Self: - """Set or update a key in the conf. - - For now only strings are supported. - We use to update the version number. - """ + def set_key(self, key: str, value: object) -> Self: with open(self.path, "rb") as yaml_file: - parser = yaml.load(yaml_file, Loader=yaml.FullLoader) + config_doc = yaml.load(yaml_file, Loader=yaml.FullLoader) - parser["commitizen"][key] = value + config_doc["commitizen"][key] = value with smart_open( self.path, "w", encoding=self._settings["encoding"] ) as yaml_file: - yaml.dump(parser, yaml_file, explicit_start=True) + yaml.dump(config_doc, yaml_file, explicit_start=True) return self From c85341e80cc72bc505fdd5728cc76569434f513c Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Mon, 15 Sep 2025 20:58:05 +0800 Subject: [PATCH 24/33] refactor(RestructuredTest): rename variable, fix typo and remove unnecessary string copy --- .../changelog_formats/restructuredtext.py | 118 +++++++++--------- .../test_changelog_format_restructuredtext.py | 14 ++- 2 files changed, 66 insertions(+), 66 deletions(-) diff --git a/commitizen/changelog_formats/restructuredtext.py b/commitizen/changelog_formats/restructuredtext.py index b7e4e105a..e8ce411e8 100644 --- a/commitizen/changelog_formats/restructuredtext.py +++ b/commitizen/changelog_formats/restructuredtext.py @@ -1,92 +1,88 @@ from __future__ import annotations -import sys from itertools import zip_longest -from typing import IO, TYPE_CHECKING, Any, Union +from typing import IO from commitizen.changelog import Metadata from .base import BaseFormat -if TYPE_CHECKING: - # TypeAlias is Python 3.10+ but backported in typing-extensions - if sys.version_info >= (3, 10): - from typing import TypeAlias - else: - from typing_extensions import TypeAlias - - -# Can't use `|` operator and native type because of https://bugs.python.org/issue42233 only fixed in 3.10 -TitleKind: TypeAlias = Union[str, tuple[str, str]] - class RestructuredText(BaseFormat): extension = "rst" - def get_metadata_from_file(self, file: IO[Any]) -> Metadata: + def get_metadata_from_file(self, file: IO[str]) -> Metadata: """ RestructuredText section titles are not one-line-based, they spread on 2 or 3 lines and levels are not predefined - but determined byt their occurrence order. + but determined by their occurrence order. It requires its own algorithm. For a more generic approach, you need to rely on `docutils`. """ - meta = Metadata() - unreleased_title_kind: TitleKind | None = None - in_overlined_title = False - lines = file.readlines() + out_metadata = Metadata() + unreleased_title_kind: str | tuple[str, str] | None = None + is_overlined_title = False + lines = [line.strip().lower() for line in file.readlines()] + for index, (first, second, third) in enumerate( zip_longest(lines, lines[1:], lines[2:], fillvalue="") ): - first = first.strip().lower() - second = second.strip().lower() - third = third.strip().lower() title: str | None = None - kind: TitleKind | None = None - if self.is_overlined_title(first, second, third): + kind: str | tuple[str, str] | None = None + if _is_overlined_title(first, second, third): title = second kind = (first[0], third[0]) - in_overlined_title = True - elif not in_overlined_title and self.is_underlined_title(first, second): + is_overlined_title = True + elif not is_overlined_title and _is_underlined_title(first, second): title = first kind = second[0] else: - in_overlined_title = False - - if title: - if "unreleased" in title: - unreleased_title_kind = kind - meta.unreleased_start = index - continue - elif unreleased_title_kind and unreleased_title_kind == kind: - meta.unreleased_end = index - # Try to find the latest release done - if version := self.tag_rules.search_version(title): - meta.latest_version = version[0] - meta.latest_version_tag = version[1] - meta.latest_version_position = index - break - if meta.unreleased_start is not None and meta.unreleased_end is None: - meta.unreleased_end = ( - meta.latest_version_position if meta.latest_version else index + 1 + is_overlined_title = False + + if not title: + continue + + if "unreleased" in title: + unreleased_title_kind = kind + out_metadata.unreleased_start = index + continue + + if unreleased_title_kind and unreleased_title_kind == kind: + out_metadata.unreleased_end = index + # Try to find the latest release done + if version := self.tag_rules.search_version(title): + out_metadata.latest_version = version[0] + out_metadata.latest_version_tag = version[1] + out_metadata.latest_version_position = index + break + + if ( + out_metadata.unreleased_start is not None + and out_metadata.unreleased_end is None + ): + out_metadata.unreleased_end = ( + out_metadata.latest_version_position + if out_metadata.latest_version + else len(lines) ) - return meta - - def is_overlined_title(self, first: str, second: str, third: str) -> bool: - return ( - len(first) >= len(second) - and len(first) == len(third) - and all(char == first[0] for char in first[1:]) - and first[0] == third[0] - and self.is_underlined_title(second, third) - ) - - def is_underlined_title(self, first: str, second: str) -> bool: - return ( - len(second) >= len(first) - and not second.isalnum() - and all(char == second[0] for char in second[1:]) - ) + return out_metadata + + +def _is_overlined_title(first: str, second: str, third: str) -> bool: + return ( + len(first) == len(third) >= len(second) + and first[0] == third[0] + and all(char == first[0] for char in first) + and _is_underlined_title(second, third) + ) + + +def _is_underlined_title(first: str, second: str) -> bool: + return ( + len(second) >= len(first) + and not second.isalnum() + and all(char == second[0] for char in second) + ) diff --git a/tests/test_changelog_format_restructuredtext.py b/tests/test_changelog_format_restructuredtext.py index 14bc15ec0..ca79620ad 100644 --- a/tests/test_changelog_format_restructuredtext.py +++ b/tests/test_changelog_format_restructuredtext.py @@ -7,7 +7,11 @@ import pytest from commitizen.changelog import Metadata -from commitizen.changelog_formats.restructuredtext import RestructuredText +from commitizen.changelog_formats.restructuredtext import ( + RestructuredText, + _is_overlined_title, + _is_underlined_title, +) from commitizen.config.base_config import BaseConfig if TYPE_CHECKING: @@ -325,9 +329,9 @@ def test_get_metadata( [(text, True) for text in UNDERLINED_TITLES] + [(text, False) for text in NOT_UNDERLINED_TITLES], ) -def test_is_underlined_title(format: RestructuredText, text: str, expected: bool): +def test_is_underlined_title(text: str, expected: bool): _, first, second = dedent(text).splitlines() - assert format.is_underlined_title(first, second) is expected + assert _is_underlined_title(first, second) is expected @pytest.mark.parametrize( @@ -335,10 +339,10 @@ def test_is_underlined_title(format: RestructuredText, text: str, expected: bool [(text, True) for text in OVERLINED_TITLES] + [(text, False) for text in NOT_OVERLINED_TITLES], ) -def test_is_overlined_title(format: RestructuredText, text: str, expected: bool): +def test_is_overlined_title(text: str, expected: bool): _, first, second, third = dedent(text).splitlines() - assert format.is_overlined_title(first, second, third) is expected + assert _is_overlined_title(first, second, third) is expected @pytest.mark.parametrize( From 5239d16f065b030110c0b13f2d88547f246dfce8 Mon Sep 17 00:00:00 2001 From: Sebastien Fusilier Date: Mon, 1 Sep 2025 14:11:38 +0000 Subject: [PATCH 25/33] feat(conventional_commits): allow exclamation in title on BC Add a configuration (disable by default) to allow exclamation in commit title to ease identify BC simple git log parsing. Signed-off-by: Sebastien Fusilier --- .../conventional_commits.py | 14 +++--- commitizen/defaults.py | 2 + docs/config.md | 10 ++++ docs/tutorials/writing_commits.md | 5 ++ tests/test_conf.py | 2 + tests/test_cz_conventional_commits.py | 49 +++++++++++++++++++ 6 files changed, 74 insertions(+), 8 deletions(-) diff --git a/commitizen/cz/conventional_commits/conventional_commits.py b/commitizen/cz/conventional_commits/conventional_commits.py index c827cebb7..acb0dfe09 100644 --- a/commitizen/cz/conventional_commits/conventional_commits.py +++ b/commitizen/cz/conventional_commits/conventional_commits.py @@ -154,17 +154,15 @@ def message(self, answers: ConventionalCommitsAnswers) -> str: # type: ignore[o footer = answers["footer"] is_breaking_change = answers["is_breaking_change"] - if scope: - # example: "fix(users): email pattern corrected" - first_line = f"{prefix}({scope}): {subject}" - else: - # example: "fix: email pattern corrected" - first_line = f"{prefix}: {subject}" - + scope = f"({scope})" if scope else "" + body = f"\n\n{body}" if body else "" + title = f"{prefix}{scope}" if is_breaking_change: + if self.config.settings.get("breaking_change_exclamation_in_title", False): + title = f"{title}!" footer = f"BREAKING CHANGE: {footer}" - return "\n\n".join(s for s in (first_line, body, footer) if s) + return f"{title}: {subject}{body}{footer}" def example(self) -> str: return ( diff --git a/commitizen/defaults.py b/commitizen/defaults.py index 4840a69b6..68e580f52 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -61,6 +61,7 @@ class Settings(TypedDict, total=False): version_scheme: str | None version_type: str | None version: str | None + breaking_change_exclamation_in_title: bool CONFIG_FILES: list[str] = [ @@ -109,6 +110,7 @@ class Settings(TypedDict, total=False): "always_signoff": False, "template": None, # default provided by plugin "extras": {}, + "breaking_change_exclamation_in_title": False, } MAJOR = "MAJOR" diff --git a/docs/config.md b/docs/config.md index 05f45a1a8..c00a6f72a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -96,6 +96,15 @@ Use annotated tags instead of lightweight tags. [See difference][annotated-tags- Create custom commit message. Useful to skip CI. [Read more][bump_message] +### `breaking_change_exclamation_in_title` + +Type: `bool` + +Default: `False` + +When true, breaking changes will be also indicated by an exclamation mark in the commit title (e.g., `feat!: breaking change`). +When false, breaking changes will be only indicated by `BREAKING CHANGE:` in the footer. [Read more][writing_commits] + ### `retry_after_failure` - Type: `bool` @@ -401,3 +410,4 @@ setup( [template-customization]: customization.md#customizing-the-changelog-template [annotated-tags-vs-lightweight]: https://stackoverflow.com/a/11514139/2047185 [encoding]: tutorials/writing_commits.md#writing-commits +[writing_commits]: tutorials/writing_commits.md#conventional-commits diff --git a/docs/tutorials/writing_commits.md b/docs/tutorials/writing_commits.md index 7d9139929..a64480874 100644 --- a/docs/tutorials/writing_commits.md +++ b/docs/tutorials/writing_commits.md @@ -13,6 +13,10 @@ add to your commit body the following `BREAKING CHANGE`. Using these three keywords will allow the proper identification of the semantic version. Of course, there are other keywords, but I'll leave it to the reader to explore them. +Note: You can also indicate breaking changes by adding an exclamation mark in the commit title +(e.g., `feat!: breaking change`) by setting the `breaking_change_exclamation_in_title` +configuration option to `true`. [Read more][breaking-change-config] + ## Writing commits Now to the important part: when writing commits, it's important to think about: @@ -44,3 +48,4 @@ Emojis may be added as well (e.g., see [cz-emoji][cz_emoji]), which requires the [conventional_commits]: https://www.conventionalcommits.org [cz_emoji]: https://commitizen-tools.github.io/commitizen/third-party-commitizen/#cz-emoji [configuration]: ../config.md#encoding +[breaking-change-config]: ../config.md#breaking_change_exclamation_in_title diff --git a/tests/test_conf.py b/tests/test_conf.py index 0953401ea..47633c7e0 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -105,6 +105,7 @@ "always_signoff": False, "template": None, "extras": {}, + "breaking_change_exclamation_in_title": False, } _new_settings: dict[str, Any] = { @@ -143,6 +144,7 @@ "always_signoff": False, "template": None, "extras": {}, + "breaking_change_exclamation_in_title": False, } diff --git a/tests/test_cz_conventional_commits.py b/tests/test_cz_conventional_commits.py index c96e03670..1742b0f3b 100644 --- a/tests/test_cz_conventional_commits.py +++ b/tests/test_cz_conventional_commits.py @@ -105,6 +105,55 @@ def test_breaking_change_in_footer(config): ) +@pytest.mark.parametrize( + "scope,breaking_change_exclamation_in_title,expected_message", + [ + # Test with scope and breaking_change_exclamation_in_title enabled + ( + "users", + True, + "feat(users)!: email pattern corrected\n\ncomplete content\n\nBREAKING CHANGE: migrate by renaming user to users", + ), + # Test without scope and breaking_change_exclamation_in_title enabled + ( + "", + True, + "feat!: email pattern corrected\n\ncomplete content\n\nBREAKING CHANGE: migrate by renaming user to users", + ), + # Test with scope and breaking_change_exclamation_in_title disabled + ( + "users", + False, + "feat(users): email pattern corrected\n\ncomplete content\n\nBREAKING CHANGE: migrate by renaming user to users", + ), + # Test without scope and breaking_change_exclamation_in_title disabled + ( + "", + False, + "feat: email pattern corrected\n\ncomplete content\n\nBREAKING CHANGE: migrate by renaming user to users", + ), + ], +) +def test_breaking_change_message_formats( + config, scope, breaking_change_exclamation_in_title, expected_message +): + # Set the breaking_change_exclamation_in_title setting + config.settings["breaking_change_exclamation_in_title"] = ( + breaking_change_exclamation_in_title + ) + conventional_commits = ConventionalCommitsCz(config) + answers = { + "prefix": "feat", + "scope": scope, + "subject": "email pattern corrected", + "is_breaking_change": True, + "body": "complete content", + "footer": "migrate by renaming user to users", + } + message = conventional_commits.message(answers) + assert message == expected_message + + def test_example(config): """just testing a string is returned. not the content""" conventional_commits = ConventionalCommitsCz(config) From 25032bace50abc45fd822c20f9ebbc6aaa02d2d0 Mon Sep 17 00:00:00 2001 From: Sebastien Fusilier Date: Tue, 9 Sep 2025 13:43:06 +0000 Subject: [PATCH 26/33] fix(test): set terminal width for cli tests Force consistent terminal width for tests to avoid wrapping differences between single and multi-worker pytest modes Signed-off-by: Sebastien Fusilier --- .../conventional_commits.py | 10 ++++--- tests/commands/test_version_command.py | 27 ++++++++++++++----- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/commitizen/cz/conventional_commits/conventional_commits.py b/commitizen/cz/conventional_commits/conventional_commits.py index acb0dfe09..f0b254eb1 100644 --- a/commitizen/cz/conventional_commits/conventional_commits.py +++ b/commitizen/cz/conventional_commits/conventional_commits.py @@ -154,15 +154,17 @@ def message(self, answers: ConventionalCommitsAnswers) -> str: # type: ignore[o footer = answers["footer"] is_breaking_change = answers["is_breaking_change"] - scope = f"({scope})" if scope else "" - body = f"\n\n{body}" if body else "" - title = f"{prefix}{scope}" + formatted_scope = f"({scope})" if scope else "" + title = f"{prefix}{formatted_scope}" if is_breaking_change: if self.config.settings.get("breaking_change_exclamation_in_title", False): title = f"{title}!" footer = f"BREAKING CHANGE: {footer}" - return f"{title}: {subject}{body}{footer}" + formatted_body = f"\n\n{body}" if body else "" + formatted_footter = f"\n\n{footer}" if footer else "" + + return f"{title}: {subject}{formatted_body}{formatted_footter}" def example(self) -> str: return ( diff --git a/tests/commands/test_version_command.py b/tests/commands/test_version_command.py index cd2e7f77e..7b5b13a7e 100644 --- a/tests/commands/test_version_command.py +++ b/tests/commands/test_version_command.py @@ -1,3 +1,4 @@ +import os import platform import sys @@ -112,13 +113,25 @@ def test_version_use_version_provider( def test_version_command_shows_description_when_use_help_option( mocker: MockerFixture, capsys, file_regression ): - testargs = ["cz", "version", "--help"] - mocker.patch.object(sys, "argv", testargs) - with pytest.raises(SystemExit): - cli.main() - - out, _ = capsys.readouterr() - file_regression.check(out, extension=".txt") + # Force consistent terminal width for tests to avoid wrapping differences + # between single and multi-worker pytest modes + original_columns = os.environ.get("COLUMNS") + os.environ["COLUMNS"] = "80" + + try: + testargs = ["cz", "version", "--help"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(SystemExit): + cli.main() + + out, _ = capsys.readouterr() + file_regression.check(out, extension=".txt") + finally: + # Restore original COLUMNS + if original_columns is not None: + os.environ["COLUMNS"] = original_columns + else: + os.environ.pop("COLUMNS", None) @pytest.mark.parametrize( From abc7acd8ca98c565edbd07647e998c635c22e929 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Wed, 10 Sep 2025 23:23:33 +0800 Subject: [PATCH 27/33] refactor(bump): cleanup related to update_version_file --- commitizen/bump.py | 81 ++++----- tests/test_bump_update_version_in_files.py | 182 ++++++++++++++++++++- 2 files changed, 207 insertions(+), 56 deletions(-) diff --git a/commitizen/bump.py b/commitizen/bump.py index 6d6b6dc06..6672c5f50 100644 --- a/commitizen/bump.py +++ b/commitizen/bump.py @@ -3,13 +3,13 @@ import os import re from collections import OrderedDict -from collections.abc import Iterable +from collections.abc import Generator, Iterable from glob import iglob from logging import getLogger from string import Template from typing import cast -from commitizen.defaults import BUMP_MESSAGE, ENCODING, MAJOR, MINOR, PATCH +from commitizen.defaults import BUMP_MESSAGE, MAJOR, MINOR, PATCH from commitizen.exceptions import CurrentVersionNotFoundError from commitizen.git import GitCommit, smart_open from commitizen.version_schemes import Increment, Version @@ -64,8 +64,8 @@ def update_version_in_files( new_version: str, files: Iterable[str], *, - check_consistency: bool = False, - encoding: str = ENCODING, + check_consistency: bool, + encoding: str, ) -> list[str]: """Change old version to the new one in every file given. @@ -75,16 +75,22 @@ def update_version_in_files( Returns the list of updated files. """ - # TODO: separate check step and write step - updated = [] - for path, regex in _files_and_regexes(files, current_version): - current_version_found, version_file = _bump_with_regex( - path, - current_version, - new_version, - regex, - encoding=encoding, - ) + updated_files = [] + + for path, pattern in _resolve_files_and_regexes(files, current_version): + current_version_found = False + bumped_lines = [] + + with open(path, encoding=encoding) as version_file: + for line in version_file: + bumped_line = ( + line.replace(current_version, new_version) + if pattern.search(line) + else line + ) + + current_version_found = current_version_found or bumped_line != line + bumped_lines.append(bumped_line) if check_consistency and not current_version_found: raise CurrentVersionNotFoundError( @@ -93,53 +99,32 @@ def update_version_in_files( "version_files are possibly inconsistent." ) + bumped_version_file_content = "".join(bumped_lines) + # Write the file out again with smart_open(path, "w", encoding=encoding) as file: - file.write(version_file) - updated.append(path) - return updated + file.write(bumped_version_file_content) + updated_files.append(path) + + return updated_files -def _files_and_regexes(patterns: Iterable[str], version: str) -> list[tuple[str, str]]: +def _resolve_files_and_regexes( + patterns: Iterable[str], version: str +) -> Generator[tuple[str, re.Pattern], None, None]: """ Resolve all distinct files with their regexp from a list of glob patterns with optional regexp """ - out: set[tuple[str, str]] = set() + filepath_set: set[tuple[str, str]] = set() for pattern in patterns: drive, tail = os.path.splitdrive(pattern) path, _, regex = tail.partition(":") filepath = drive + path - if not regex: - regex = re.escape(version) + regex = regex or re.escape(version) - for file in iglob(filepath): - out.add((file, regex)) + filepath_set.update((path, regex) for path in iglob(filepath)) - return sorted(out) - - -def _bump_with_regex( - version_filepath: str, - current_version: str, - new_version: str, - regex: str, - encoding: str = ENCODING, -) -> tuple[bool, str]: - current_version_found = False - lines = [] - pattern = re.compile(regex) - with open(version_filepath, encoding=encoding) as f: - for line in f: - if not pattern.search(line): - lines.append(line) - continue - - bumped_line = line.replace(current_version, new_version) - if bumped_line != line: - current_version_found = True - lines.append(bumped_line) - - return current_version_found, "".join(lines) + return ((path, re.compile(regex)) for path, regex in sorted(filepath_set)) def create_commit_message( diff --git a/tests/test_bump_update_version_in_files.py b/tests/test_bump_update_version_in_files.py index c14e4ad1c..5a74c1c45 100644 --- a/tests/test_bump_update_version_in_files.py +++ b/tests/test_bump_update_version_in_files.py @@ -103,7 +103,11 @@ def test_update_version_in_files(version_files, file_regression): old_version = "1.2.3" new_version = "2.0.0" bump.update_version_in_files( - old_version, new_version, version_files, encoding="utf-8" + old_version, + new_version, + version_files, + check_consistency=False, + encoding="utf-8", ) file_contents = "" @@ -119,7 +123,9 @@ def test_partial_update_of_file(version_repeated_file, file_regression): regex = "version" location = f"{version_repeated_file}:{regex}" - bump.update_version_in_files(old_version, new_version, [location], encoding="utf-8") + bump.update_version_in_files( + old_version, new_version, [location], check_consistency=False, encoding="utf-8" + ) with open(version_repeated_file, encoding="utf-8") as f: file_regression.check(f.read(), extension=".json") @@ -129,7 +135,9 @@ def test_random_location(random_location_version_file, file_regression): new_version = "2.0.0" location = f"{random_location_version_file}:version.+Commitizen" - bump.update_version_in_files(old_version, new_version, [location], encoding="utf-8") + bump.update_version_in_files( + old_version, new_version, [location], check_consistency=False, encoding="utf-8" + ) with open(random_location_version_file, encoding="utf-8") as f: file_regression.check(f.read(), extension=".lock") @@ -141,7 +149,9 @@ def test_duplicates_are_change_with_no_regex( new_version = "2.0.0" location = f"{random_location_version_file}:version" - bump.update_version_in_files(old_version, new_version, [location], encoding="utf-8") + bump.update_version_in_files( + old_version, new_version, [location], check_consistency=False, encoding="utf-8" + ) with open(random_location_version_file, encoding="utf-8") as f: file_regression.check(f.read(), extension=".lock") @@ -153,7 +163,9 @@ def test_version_bump_increase_string_length( new_version = "1.2.10" location = f"{multiple_versions_increase_string}:version" - bump.update_version_in_files(old_version, new_version, [location], encoding="utf-8") + bump.update_version_in_files( + old_version, new_version, [location], check_consistency=False, encoding="utf-8" + ) with open(multiple_versions_increase_string, encoding="utf-8") as f: file_regression.check(f.read(), extension=".txt") @@ -165,7 +177,9 @@ def test_version_bump_reduce_string_length( new_version = "2.0.0" location = f"{multiple_versions_reduce_string}:version" - bump.update_version_in_files(old_version, new_version, [location], encoding="utf-8") + bump.update_version_in_files( + old_version, new_version, [location], check_consistency=False, encoding="utf-8" + ) with open(multiple_versions_reduce_string, encoding="utf-8") as f: file_regression.check(f.read(), extension=".txt") @@ -204,7 +218,9 @@ def test_multiple_versions_to_bump( new_version = "1.2.10" location = f"{multiple_versions_to_update_poetry_lock}:version" - bump.update_version_in_files(old_version, new_version, [location], encoding="utf-8") + bump.update_version_in_files( + old_version, new_version, [location], check_consistency=False, encoding="utf-8" + ) with open(multiple_versions_to_update_poetry_lock, encoding="utf-8") as f: file_regression.check(f.read(), extension=".toml") @@ -220,8 +236,158 @@ def test_update_version_in_globbed_files(commitizen_config_file, file_regression version_files = [commitizen_config_file.dirpath("*.toml")] bump.update_version_in_files( - old_version, new_version, version_files, encoding="utf-8" + old_version, + new_version, + version_files, + check_consistency=False, + encoding="utf-8", ) for file in commitizen_config_file, other: file_regression.check(file.read_text("utf-8"), extension=".toml") + + +def test_update_version_in_files_with_check_consistency_true(version_files): + """Test update_version_in_files with check_consistency=True (success case).""" + old_version = "1.2.3" + new_version = "2.0.0" + + # This should succeed because all files contain the current version + updated_files = bump.update_version_in_files( + old_version, + new_version, + version_files, + check_consistency=True, + encoding="utf-8", + ) + + # Verify that all files were updated + assert len(updated_files) == len(version_files) + for file_path in updated_files: + assert file_path in version_files + + +def test_update_version_in_files_with_check_consistency_true_failure( + commitizen_config_file, inconsistent_python_version_file +): + """Test update_version_in_files with check_consistency=True (failure case).""" + old_version = "1.2.3" + new_version = "2.0.0" + 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: + bump.update_version_in_files( + old_version, + new_version, + version_files, + check_consistency=True, + 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", + [ + ("latin-1", "test_latin1.txt"), + ("utf-16", "test_utf16.txt"), + ], + ids=["latin-1", "utf-16"], +) +def test_update_version_in_files_with_different_encodings(tmp_path, encoding, filename): + """Test update_version_in_files with different encodings.""" + # Create a test file with the specified encoding + test_file = tmp_path / filename + content = f'version = "1.2.3"\n# This is a test file with {encoding} encoding\n' + test_file.write_text(content, encoding=encoding) + + old_version = "1.2.3" + new_version = "2.0.0" + + updated_files = bump.update_version_in_files( + old_version, + new_version, + [str(test_file)], + check_consistency=True, + encoding=encoding, + ) + + # Verify the file was updated + assert len(updated_files) == 1 + assert str(test_file) in updated_files + + # Verify the content was updated correctly + updated_content = test_file.read_text(encoding=encoding) + assert f'version = "{new_version}"' in updated_content + assert f'version = "{old_version}"' not in updated_content + + +def test_update_version_in_files_return_value(version_files): + """Test that update_version_in_files returns the correct list of updated files.""" + old_version = "1.2.3" + new_version = "2.0.0" + + updated_files = bump.update_version_in_files( + old_version, + new_version, + version_files, + check_consistency=False, + encoding="utf-8", + ) + + # Verify return value is a list + assert isinstance(updated_files, list) + + # Verify all files in the input are in the returned list + assert len(updated_files) == len(version_files) + for file_path in version_files: + assert file_path in updated_files + + # Verify the returned paths are strings + for file_path in updated_files: + assert isinstance(file_path, str) + + +def test_update_version_in_files_return_value_partial_update(tmp_path): + """Test return value when only some files are updated.""" + # Create two test files + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + + # File1 contains the version to update + file1.write_text('version = "1.2.3"\n') + + # File2 doesn't contain the version + file2.write_text("some other content\n") + + old_version = "1.2.3" + new_version = "2.0.0" + + updated_files = bump.update_version_in_files( + old_version, + new_version, + [str(file1), str(file2)], + check_consistency=False, + encoding="utf-8", + ) + + # Verify return value + assert isinstance(updated_files, list) + assert len(updated_files) == 2 # Both files should be in the list + assert str(file1) in updated_files + assert str(file2) in updated_files + + # Verify file1 was actually updated + content1 = file1.read_text(encoding="utf-8") + assert f'version = "{new_version}"' in content1 + + # Verify file2 was not changed + content2 = file2.read_text(encoding="utf-8") + assert content2 == "some other content\n" From b181b44b97e5daf1cf2a2b8b680b8c17e08eeec5 Mon Sep 17 00:00:00 2001 From: Wei Lee Date: Sat, 8 Nov 2025 17:08:06 +0800 Subject: [PATCH 28/33] test: simplify assertion --- tests/test_bump_update_version_in_files.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/test_bump_update_version_in_files.py b/tests/test_bump_update_version_in_files.py index 5a74c1c45..a1df2f34b 100644 --- a/tests/test_bump_update_version_in_files.py +++ b/tests/test_bump_update_version_in_files.py @@ -346,13 +346,10 @@ def test_update_version_in_files_return_value(version_files): assert isinstance(updated_files, list) # Verify all files in the input are in the returned list - assert len(updated_files) == len(version_files) - for file_path in version_files: - assert file_path in updated_files + assert set(version_files) == set(updated_files) # Verify the returned paths are strings - for file_path in updated_files: - assert isinstance(file_path, str) + assert all(isinstance(file_path, str) for file_path in updated_files) def test_update_version_in_files_return_value_partial_update(tmp_path): From 3b499b914402e156c15bfb24a26fae0f60659c40 Mon Sep 17 00:00:00 2001 From: Wei Lee Date: Sat, 8 Nov 2025 17:59:37 +0800 Subject: [PATCH 29/33] test: replace tmpdir with tmppath --- tests/test_bump_update_version_in_files.py | 79 ++++++++++++---------- 1 file changed, 42 insertions(+), 37 deletions(-) diff --git a/tests/test_bump_update_version_in_files.py b/tests/test_bump_update_version_in_files.py index a1df2f34b..9d53a3e81 100644 --- a/tests/test_bump_update_version_in_files.py +++ b/tests/test_bump_update_version_in_files.py @@ -2,6 +2,7 @@ from shutil import copyfile import pytest +from _pytest.fixtures import FixtureRequest from commitizen import bump from commitizen.exceptions import CurrentVersionNotFoundError @@ -21,40 +22,40 @@ def _copy_sample_file_to_tmpdir( @pytest.fixture(scope="function") -def commitizen_config_file(tmpdir): +def commitizen_config_file(tmp_path: Path) -> Path: return _copy_sample_file_to_tmpdir( - tmpdir, "sample_pyproject.toml", "pyproject.toml" + tmp_path, "sample_pyproject.toml", "pyproject.toml" ) @pytest.fixture(scope="function") -def python_version_file(tmpdir, request): - return _copy_sample_file_to_tmpdir(tmpdir, "sample_version.py", "__version__.py") +def python_version_file(tmp_path: Path, request: FixtureRequest) -> Path: + return _copy_sample_file_to_tmpdir(tmp_path, "sample_version.py", "__version__.py") @pytest.fixture(scope="function") -def inconsistent_python_version_file(tmpdir): +def inconsistent_python_version_file(tmp_path: Path) -> Path: return _copy_sample_file_to_tmpdir( - tmpdir, "inconsistent_version.py", "__version__.py" + tmp_path, "inconsistent_version.py", "__version__.py" ) @pytest.fixture(scope="function") -def random_location_version_file(tmpdir): - return _copy_sample_file_to_tmpdir(tmpdir, "sample_cargo.lock", "Cargo.lock") +def random_location_version_file(tmp_path: Path) -> Path: + return _copy_sample_file_to_tmpdir(tmp_path, "sample_cargo.lock", "Cargo.lock") @pytest.fixture(scope="function") -def version_repeated_file(tmpdir): +def version_repeated_file(tmp_path: Path) -> Path: return _copy_sample_file_to_tmpdir( - tmpdir, "repeated_version_number.json", "package.json" + tmp_path, "repeated_version_number.json", "package.json" ) @pytest.fixture(scope="function") -def docker_compose_file(tmpdir): +def docker_compose_file(tmp_path: Path) -> Path: return _copy_sample_file_to_tmpdir( - tmpdir, "sample_docker_compose.yaml", "docker-compose.yaml" + tmp_path, "sample_docker_compose.yaml", "docker-compose.yaml" ) @@ -66,36 +67,38 @@ def docker_compose_file(tmpdir): ), ids=("with_eol", "without_eol"), ) -def multiple_versions_to_update_poetry_lock(tmpdir, request): - return _copy_sample_file_to_tmpdir(tmpdir, request.param, "pyproject.toml") +def multiple_versions_to_update_poetry_lock( + tmp_path: Path, request: FixtureRequest +) -> Path: + return _copy_sample_file_to_tmpdir(tmp_path, request.param, "pyproject.toml") @pytest.fixture(scope="function") -def multiple_versions_increase_string(tmpdir): - tmp_file = tmpdir.join("anyfile") - tmp_file.write(MULTIPLE_VERSIONS_INCREASE_STRING) +def multiple_versions_increase_string(tmp_path: Path) -> str: + tmp_file = tmp_path / "anyfile" + tmp_file.write_text(MULTIPLE_VERSIONS_INCREASE_STRING) return str(tmp_file) @pytest.fixture(scope="function") -def multiple_versions_reduce_string(tmpdir): - tmp_file = tmpdir.join("anyfile") - tmp_file.write(MULTIPLE_VERSIONS_REDUCE_STRING) +def multiple_versions_reduce_string(tmp_path: Path) -> str: + tmp_file = tmp_path / "anyfile" + tmp_file.write_text(MULTIPLE_VERSIONS_REDUCE_STRING) return str(tmp_file) @pytest.fixture(scope="function") def version_files( - commitizen_config_file, - python_version_file, - version_repeated_file, - docker_compose_file, -): + commitizen_config_file: Path, + python_version_file: Path, + version_repeated_file: Path, + docker_compose_file: Path, +) -> tuple[str, ...]: return ( - commitizen_config_file, - python_version_file, - version_repeated_file, - docker_compose_file, + str(commitizen_config_file), + str(python_version_file), + str(version_repeated_file), + str(docker_compose_file), ) @@ -228,12 +231,14 @@ def test_multiple_versions_to_bump( def test_update_version_in_globbed_files(commitizen_config_file, file_regression): old_version = "1.2.3" new_version = "2.0.0" - other = commitizen_config_file.dirpath("other.toml") - print(commitizen_config_file, other) + other = commitizen_config_file.parent / "other.toml" + copyfile(commitizen_config_file, other) # Prepend full path as test assume absolute paths or cwd-relative - version_files = [commitizen_config_file.dirpath("*.toml")] + version_files = [ + str(file_path) for file_path in commitizen_config_file.parent.glob("*.toml") + ] bump.update_version_in_files( old_version, @@ -247,13 +252,15 @@ def test_update_version_in_globbed_files(commitizen_config_file, file_regression file_regression.check(file.read_text("utf-8"), extension=".toml") -def test_update_version_in_files_with_check_consistency_true(version_files): +def test_update_version_in_files_with_check_consistency_true( + version_files: tuple[str, ...], +): """Test update_version_in_files with check_consistency=True (success case).""" old_version = "1.2.3" new_version = "2.0.0" # This should succeed because all files contain the current version - updated_files = bump.update_version_in_files( + updated_files: list[str] = bump.update_version_in_files( old_version, new_version, version_files, @@ -262,9 +269,7 @@ def test_update_version_in_files_with_check_consistency_true(version_files): ) # Verify that all files were updated - assert len(updated_files) == len(version_files) - for file_path in updated_files: - assert file_path in version_files + assert set(updated_files) == set(version_files) def test_update_version_in_files_with_check_consistency_true_failure( From c43197bcd7dcf84ea1c5cf8b149db7c5c8e9856f Mon Sep 17 00:00:00 2001 From: catfish Date: Mon, 11 Aug 2025 23:54:31 +0800 Subject: [PATCH 30/33] feat: add config option for line length warning --- commitizen/cli.py | 2 - commitizen/commands/check.py | 21 +++++++--- commitizen/commands/commit.py | 7 +++- commitizen/defaults.py | 2 + poetry.lock | 2 +- pyproject.toml | 2 +- tests/commands/test_check_command.py | 59 ++++++++++++++++++++++++++- tests/commands/test_commit_command.py | 59 +++++++++++++++++++++++++++ tests/test_conf.py | 2 + 9 files changed, 143 insertions(+), 13 deletions(-) diff --git a/commitizen/cli.py b/commitizen/cli.py index e9689d75f..c11e9078d 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -160,7 +160,6 @@ def __call__( { "name": ["-l", "--message-length-limit"], "type": int, - "default": 0, "help": "length limit of the commit message; 0 for no limit", }, { @@ -499,7 +498,6 @@ def __call__( { "name": ["-l", "--message-length-limit"], "type": int, - "default": 0, "help": "length limit of the commit message; 0 for no limit", }, ], diff --git a/commitizen/commands/check.py b/commitizen/commands/check.py index d45e388f9..a6101f7df 100644 --- a/commitizen/commands/check.py +++ b/commitizen/commands/check.py @@ -7,6 +7,7 @@ from commitizen import factory, git, out from commitizen.config import BaseConfig from commitizen.exceptions import ( + CommitMessageLengthExceededError, InvalidCommandArgumentError, InvalidCommitMessageError, NoCommitsFoundError, @@ -18,7 +19,7 @@ class CheckArgs(TypedDict, total=False): commit_msg: str rev_range: str allow_abort: bool - message_length_limit: int + message_length_limit: int | None allowed_prefixes: list[str] message: str use_default_range: bool @@ -41,8 +42,11 @@ def __init__(self, config: BaseConfig, arguments: CheckArgs, *args: object) -> N self.allow_abort = bool( arguments.get("allow_abort", config.settings["allow_abort"]) ) + self.use_default_range = bool(arguments.get("use_default_range")) - self.max_msg_length = arguments.get("message_length_limit", 0) + self.max_msg_length = arguments.get( + "message_length_limit", config.settings.get("message_length_limit", None) + ) # we need to distinguish between None and [], which is a valid value allowed_prefixes = arguments.get("allowed_prefixes") @@ -88,7 +92,7 @@ def __call__(self) -> None: invalid_msgs_content = "\n".join( f'commit "{commit.rev}": "{commit.message}"' for commit in commits - if not self._validate_commit_message(commit.message, pattern) + if not self._validate_commit_message(commit.message, pattern, commit.rev) ) if invalid_msgs_content: # TODO: capitalize the first letter of the error message for consistency in v5 @@ -153,7 +157,7 @@ def _filter_comments(msg: str) -> str: return "\n".join(lines) def _validate_commit_message( - self, commit_msg: str, pattern: re.Pattern[str] + self, commit_msg: str, pattern: re.Pattern[str], commit_hash: str ) -> bool: if not commit_msg: return self.allow_abort @@ -161,9 +165,14 @@ def _validate_commit_message( if any(map(commit_msg.startswith, self.allowed_prefixes)): return True - if self.max_msg_length: + if self.max_msg_length is not None: msg_len = len(commit_msg.partition("\n")[0].strip()) if msg_len > self.max_msg_length: - return False + raise CommitMessageLengthExceededError( + f"commit validation: failed!\n" + f"commit message length exceeds the limit.\n" + f'commit "{commit_hash}": "{commit_msg}"\n' + f"message length limit: {self.max_msg_length} (actual: {msg_len})" + ) return bool(pattern.match(commit_msg)) diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index d16b4d8d8..7144bced8 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -33,7 +33,7 @@ class CommitArgs(TypedDict, total=False): dry_run: bool edit: bool extra_cli_args: str - message_length_limit: int + message_length_limit: int | None no_retry: bool signoff: bool write_message_to_file: Path | None @@ -80,8 +80,11 @@ def _get_message_by_prompt_commit_questions(self) -> str: raise NoAnswersError() message = self.cz.message(answers) - if limit := self.arguments.get("message_length_limit", 0): + if limit := self.arguments.get( + "message_length_limit", self.config.settings.get("message_length_limit", 0) + ): self._validate_subject_length(message=message, length_limit=limit) + return message def _validate_subject_length(self, *, message: str, length_limit: int) -> None: diff --git a/commitizen/defaults.py b/commitizen/defaults.py index 68e580f52..9b3b76a68 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -46,6 +46,7 @@ class Settings(TypedDict, total=False): ignored_tag_formats: Sequence[str] legacy_tag_formats: Sequence[str] major_version_zero: bool + message_length_limit: int | None name: str post_bump_hooks: list[str] | None pre_bump_hooks: list[str] | None @@ -111,6 +112,7 @@ class Settings(TypedDict, total=False): "template": None, # default provided by plugin "extras": {}, "breaking_change_exclamation_in_title": False, + "message_length_limit": None, # None for no limit } MAJOR = "MAJOR" diff --git a/poetry.lock b/poetry.lock index 1a731e4f0..49df9dcd5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1955,4 +1955,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<4.0" -content-hash = "bdc8773ed978a4265a2a099265db7e116d2f65c467c4980d984e546716cea244" +content-hash = "cd5648d8aad7b58913b1c0e4cd4f04c98d5bcfa7e4ef8e7bb994a59492d7d4a2" diff --git a/pyproject.toml b/pyproject.toml index f16ad596b..4ff252ebf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "colorama (>=0.4.1,<1.0)", "termcolor (>=1.1.0,<4.0.0)", "packaging>=19", - "tomlkit (>=0.5.3,<1.0.0)", + "tomlkit (>=0.8.0,<1.0.0)", "jinja2>=2.10.3", "pyyaml>=3.08", "argcomplete >=1.12.1,<3.7", diff --git a/tests/commands/test_check_command.py b/tests/commands/test_check_command.py index 365a556dd..d2a82a903 100644 --- a/tests/commands/test_check_command.py +++ b/tests/commands/test_check_command.py @@ -8,6 +8,7 @@ from commitizen import cli, commands, git from commitizen.exceptions import ( + CommitMessageLengthExceededError, InvalidCommandArgumentError, InvalidCommitMessageError, NoCommitsFoundError, @@ -449,7 +450,7 @@ def test_check_command_with_message_length_limit_exceeded(config, mocker: MockFi arguments={"message": message, "message_length_limit": len(message) - 1}, ) - with pytest.raises(InvalidCommitMessageError): + with pytest.raises(CommitMessageLengthExceededError): check_cmd() error_mock.assert_called_once() @@ -460,3 +461,59 @@ def test_check_command_with_amend_prefix_default(config, mocker: MockFixture): check_cmd() success_mock.assert_called_once() + + +def test_check_command_with_config_message_length_limit(config, mocker: MockFixture): + success_mock = mocker.patch("commitizen.out.success") + message = "fix(scope): some commit message" + + config.settings["message_length_limit"] = len(message) + 1 + + check_cmd = commands.Check( + config=config, + arguments={"message": message}, + ) + + check_cmd() + success_mock.assert_called_once() + + +def test_check_command_with_config_message_length_limit_exceeded( + config, mocker: MockFixture +): + error_mock = mocker.patch("commitizen.out.error") + message = "fix(scope): some commit message" + + config.settings["message_length_limit"] = len(message) - 1 + + check_cmd = commands.Check( + config=config, + arguments={"message": message}, + ) + + with pytest.raises(CommitMessageLengthExceededError): + check_cmd() + error_mock.assert_called_once() + + +def test_check_command_cli_overrides_config_message_length_limit( + config, mocker: MockFixture +): + success_mock = mocker.patch("commitizen.out.success") + message = "fix(scope): some commit message" + + config.settings["message_length_limit"] = len(message) - 1 + + check_cmd = commands.Check( + config=config, + arguments={"message": message, "message_length_limit": len(message) + 1}, + ) + + check_cmd() + success_mock.assert_called_once() + + success_mock.reset_mock() + check_cmd = commands.Check( + config=config, + arguments={"message": message, "message_length_limit": None}, + ) diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index 8d0181f3e..3e408576f 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -554,3 +554,62 @@ def test_commit_when_nothing_added_to_commit(config, mocker: MockFixture, out): commit_mock.assert_called_once() error_mock.assert_called_once_with(out) + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_config_message_length_limit(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") + prefix = "feat" + subject = "random subject" + message_length = len(prefix) + len(": ") + len(subject) + prompt_mock.return_value = { + "prefix": prefix, + "subject": subject, + "scope": "", + "is_breaking_change": False, + "body": "random body", + "footer": "random footer", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + + config.settings["message_length_limit"] = message_length + commands.Commit(config, {})() + success_mock.assert_called_once() + + config.settings["message_length_limit"] = message_length - 1 + with pytest.raises(CommitMessageLengthExceededError): + commands.Commit(config, {})() + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_cli_overrides_config_message_length_limit( + config, mocker: MockFixture +): + prompt_mock = mocker.patch("questionary.prompt") + prefix = "feat" + subject = "random subject" + message_length = len(prefix) + len(": ") + len(subject) + prompt_mock.return_value = { + "prefix": prefix, + "subject": subject, + "scope": "", + "is_breaking_change": False, + "body": "random body", + "footer": "random footer", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + + config.settings["message_length_limit"] = message_length - 1 + + commands.Commit(config, {"message_length_limit": message_length})() + success_mock.assert_called_once() + + success_mock.reset_mock() + commands.Commit(config, {"message_length_limit": None})() + success_mock.assert_called_once() diff --git a/tests/test_conf.py b/tests/test_conf.py index 47633c7e0..bbbed41e0 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -106,6 +106,7 @@ "template": None, "extras": {}, "breaking_change_exclamation_in_title": False, + "message_length_limit": None, } _new_settings: dict[str, Any] = { @@ -145,6 +146,7 @@ "template": None, "extras": {}, "breaking_change_exclamation_in_title": False, + "message_length_limit": None, } From 3c0a88c18ef97c2b90be19bfa8a7e5c8e9d9b53d Mon Sep 17 00:00:00 2001 From: catfish Date: Tue, 12 Aug 2025 13:06:27 +0800 Subject: [PATCH 31/33] docs(config): add message length limit configuration option --- docs/config.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/config.md b/docs/config.md index c00a6f72a..94aa2076b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -119,6 +119,14 @@ Automatically retry failed commit when running `cz commit`. [Read more][retry_af Disallow empty commit messages. Useful in CI. [Read more][allow_abort] +### `message_length_limit` + +Type: `int` + +Default: `0` + +Maximum length of the commit message. Setting it to `0` disables the length limit. It can be overridden by the `-l/--message-length-limit` command line argument. + ### `allowed_prefixes` - Type: `list` From 9dce8f15aed624f4dfefce3d63e42a1e3dda0e19 Mon Sep 17 00:00:00 2001 From: Adrian DC Date: Tue, 13 Aug 2024 17:35:37 +0200 Subject: [PATCH 32/33] style: unify YAML quotes style Signed-off-by: Adrian DC --- docs/customization.md | 14 +++++++------- tests/test_cz_customize.py | 16 +++++++++++----- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/docs/customization.md b/docs/customization.md index df7717107..99ffd39ba 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -110,13 +110,13 @@ And the correspondent example for a yaml file: commitizen: name: cz_customize customize: - message_template: "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + message_template: '{{change_type}}:{% if show_message %} {{message}}{% endif %}' example: 'feature: this feature enable customize through config file' - schema: ": " - schema_pattern: "(feature|bug fix):(\\s.*)" - bump_pattern: "^(break|new|fix|hotfix)" - commit_parser: "^(?Pfeature|bug fix):\\s(?P.*)?" - changelog_pattern: "^(feature|bug fix)?(!)?" + schema: ': ' + schema_pattern: '(feature|bug fix):(\\s.*)' + bump_pattern: '^(break|new|fix|hotfix)' + commit_parser: '^(?Pfeature|bug fix):\\s(?P.*)?' + changelog_pattern: '^(feature|bug fix)?(!)?' change_type_map: feature: Feat bug fix: Fix @@ -125,7 +125,7 @@ commitizen: new: MINOR fix: PATCH hotfix: PATCH - change_type_order: ["BREAKING CHANGE", "feat", "fix", "refactor", "perf"] + change_type_order: ['BREAKING CHANGE', 'feat', 'fix', 'refactor', 'perf'] info_path: cz_customize_info.txt info: This is customized info questions: diff --git a/tests/test_cz_customize.py b/tests/test_cz_customize.py index 76341828d..dd354d65e 100644 --- a/tests/test_cz_customize.py +++ b/tests/test_cz_customize.py @@ -110,17 +110,22 @@ - commitizen/__version__.py - pyproject.toml customize: - message_template: "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + message_template: '{{change_type}}:{% if show_message %} {{message}}{% endif %}' example: 'feature: this feature enables customization through a config file' - schema: ": " - schema_pattern: "(feature|bug fix):(\\s.*)" - bump_pattern: "^(break|new|fix|hotfix)" + schema: ': ' + schema_pattern: '(feature|bug fix):(\\s.*)' + bump_pattern: '^(break|new|fix|hotfix)' + commit_parser: '^(?Pfeature|bug fix):\\s(?P.*)?' + changelog_pattern: '^(feature|bug fix)?(!)?' + change_type_map: + feature: Feat + bug fix: Fix bump_map: break: MAJOR new: MINOR fix: PATCH hotfix: PATCH - change_type_order: ["perf", "BREAKING CHANGE", "feat", "fix", "refactor"] + change_type_order: ['perf', 'BREAKING CHANGE', 'feat', 'fix', 'refactor'] info: This is a customized cz. questions: - type: list @@ -326,6 +331,7 @@ params=[ TomlConfig(data=TOML_STR, path=Path("not_exist.toml")), JsonConfig(data=JSON_STR, path=Path("not_exist.json")), + YAMLConfig(data=YAML_STR, path=Path("not_exist.yaml")), ] ) def config(request): From 0695a2fa779be659bc90cd685196688af80b135b Mon Sep 17 00:00:00 2001 From: Wei Lee Date: Sun, 9 Nov 2025 09:59:15 +0800 Subject: [PATCH 33/33] fix(commands/version): add missing return --- commitizen/commands/version.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/commitizen/commands/version.py b/commitizen/commands/version.py index 04fa664f2..7ccadb513 100644 --- a/commitizen/commands/version.py +++ b/commitizen/commands/version.py @@ -48,6 +48,8 @@ def __call__(self) -> None: version_scheme = get_version_scheme(self.config.settings) except VersionSchemeUnknown: out.error("Unknown version scheme.") + return + _version = version_scheme(version) if self.parameter.get("major"):