From a36b7ef7e81429ed61df6072b2b5edeecf493564 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Fri, 16 May 2025 02:18:37 +0800 Subject: [PATCH 1/4] refactor(init): code cleanup and better test coverage --- commitizen/commands/init.py | 378 ++++++++++++++-------------- commitizen/git.py | 2 +- tests/commands/test_init_command.py | 231 +++++++++++++++-- tests/test_git.py | 6 +- 4 files changed, 410 insertions(+), 207 deletions(-) diff --git a/commitizen/commands/init.py b/commitizen/commands/init.py index 20277399d..6475fc069 100644 --- a/commitizen/commands/init.py +++ b/commitizen/commands/init.py @@ -14,7 +14,12 @@ from commitizen.defaults import DEFAULT_SETTINGS, config_files from commitizen.exceptions import InitFailedError, NoAnswersError from commitizen.git import get_latest_tag_name, get_tag_names, smart_open -from commitizen.version_schemes import KNOWN_SCHEMES, Version, get_version_scheme +from commitizen.version_schemes import ( + KNOWN_SCHEMES, + Version, + VersionScheme, + get_version_scheme, +) class ProjectInfo: @@ -63,22 +68,14 @@ def is_npm_package(self) -> bool: def is_php_composer(self) -> bool: return os.path.isfile("composer.json") - @property - def latest_tag(self) -> str | None: - return get_latest_tag_name() - - def tags(self) -> list | None: - """Not a property, only use if necessary""" - if self.latest_tag is None: - return None - return get_tag_names() - @property def is_pre_commit_installed(self) -> bool: return bool(shutil.which("pre-commit")) class Init: + _PRE_COMMIT_CONFIG_FILENAME = ".pre-commit-config.yaml" + def __init__(self, config: BaseConfig, *args): self.config: BaseConfig = config self.encoding = config.settings["encoding"] @@ -113,75 +110,51 @@ def __call__(self): except KeyboardInterrupt: raise InitFailedError("Stopped by user") - # 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) - values_to_add = {} - values_to_add["name"] = cz_name - values_to_add["tag_format"] = tag_format - values_to_add["version_scheme"] = version_scheme - - if version_provider == "commitizen": - values_to_add["version"] = version.public - else: - values_to_add["version_provider"] = version_provider - - if update_changelog_on_bump: - values_to_add["update_changelog_on_bump"] = update_changelog_on_bump - - if major_version_zero: - values_to_add["major_version_zero"] = major_version_zero + self._init_config(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}") + self._init_pre_commit_hook() - # Create and initialize config self.config.init_empty_config_content() - self._update_config_file(values_to_add) + self._update_config_file( + version_provider, + version, + name=cz_name, + tag_format=tag_format, + version_scheme=version_scheme, + 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: - default_path = ".cz.toml" - if self.project_info.has_pyproject: - default_path = "pyproject.toml" - - name: str = questionary.select( - "Please choose a supported config file: ", - choices=config_files, - default=default_path, - style=self.cz.style, - ).unsafe_ask() - return name + default_path = ( + "pyproject.toml" if self.project_info.has_pyproject else ".cz.toml" + ) + + return str( + questionary.select( + "Please choose a supported config file: ", + choices=config_files, + default=default_path, + style=self.cz.style, + ).unsafe_ask() + ) def _ask_name(self) -> str: - name: str = questionary.select( - "Please choose a cz (commit rule): (default: cz_conventional_commits)", - choices=list(registry.keys()), - default="cz_conventional_commits", - style=self.cz.style, - ).unsafe_ask() - return name + return str( + questionary.select( + "Please choose a cz (commit rule): (default: cz_conventional_commits)", + choices=list(registry.keys()), + default="cz_conventional_commits", + style=self.cz.style, + ).unsafe_ask() + ) def _ask_tag(self) -> str: - latest_tag = self.project_info.latest_tag + latest_tag = get_latest_tag_name() if not latest_tag: out.error("No Existing Tag. Set tag to v0.0.1") return "0.0.1" @@ -189,118 +162,124 @@ def _ask_tag(self) -> str: is_correct_tag = questionary.confirm( f"Is {latest_tag} the latest tag?", style=self.cz.style, default=False ).unsafe_ask() - if not is_correct_tag: - tags = self.project_info.tags() - if not tags: - out.error("No Existing Tag. Set tag to v0.0.1") - return "0.0.1" - - # the latest tag is most likely with the largest number. Thus list the tags in reverse order makes more sense - sorted_tags = sorted(tags, reverse=True) - latest_tag = questionary.select( + if is_correct_tag: + return latest_tag + + tags = get_tag_names() + if not tags: + out.error("No Existing Tag. Set tag to v0.0.1") + return "0.0.1" + + # Tags are sorted in reverse order to get the most recent tag first, + # which is typically the one we want to use as a reference. + latest_tag = str( + questionary.select( "Please choose the latest tag: ", - choices=sorted_tags, + choices=sorted(tags, reverse=True), style=self.cz.style, ).unsafe_ask() + ) + + if not latest_tag: + raise NoAnswersError("Tag is required!") - if not latest_tag: - raise NoAnswersError("Tag is required!") return latest_tag def _ask_tag_format(self, latest_tag) -> str: - is_correct_format = False if latest_tag.startswith("v"): tag_format = r"v$version" is_correct_format = questionary.confirm( f'Is "{tag_format}" the correct tag format?', style=self.cz.style ).unsafe_ask() + if is_correct_format: + return tag_format default_format = DEFAULT_SETTINGS["tag_format"] - if not is_correct_format: - tag_format = questionary.text( - f'Please enter the correct version format: (default: "{default_format}")', - style=self.cz.style, - ).unsafe_ask() + tag_format = questionary.text( + f'Please enter the correct version format: (default: "{default_format}")', + style=self.cz.style, + ).unsafe_ask() - if not tag_format: - tag_format = default_format - return tag_format + return tag_format or default_format def _ask_version_provider(self) -> str: """Ask for setting: version_provider""" OPTS = { - "commitizen": "commitizen: Fetch and set version in commitizen config (default)", - "cargo": "cargo: Get and set version from Cargo.toml:project.version field", - "composer": "composer: Get and set version from composer.json:project.version field", - "npm": "npm: Get and set version from package.json:project.version field", - "pep621": "pep621: Get and set version from pyproject.toml:project.version field", - "poetry": "poetry: Get and set version from pyproject.toml:tool.poetry.version field", - "uv": "uv: Get and Get and set version from pyproject.toml and uv.lock", - "scm": "scm: Fetch the version from git and does not need to set it back", + "commitizen": "Fetch and set version in commitizen config (default)", + "cargo": "Get and set version from Cargo.toml:project.version field", + "composer": "Get and set version from composer.json:project.version field", + "npm": "Get and set version from package.json:project.version field", + "pep621": "Get and set version from pyproject.toml:project.version field", + "poetry": "Get and set version from pyproject.toml:tool.poetry.version field", + "uv": "Get and set version from pyproject.toml and uv.lock", + "scm": "Fetch the version from git and does not need to set it back", } - default_val = "commitizen" - if self.project_info.is_python: - if self.project_info.is_python_poetry: - default_val = "poetry" - elif self.project_info.is_python_uv: - default_val = "uv" - else: - default_val = "pep621" - elif self.project_info.is_rust_cargo: - default_val = "cargo" - elif self.project_info.is_npm_package: - default_val = "npm" - elif self.project_info.is_php_composer: - default_val = "composer" - choices = [ - questionary.Choice(title=title, value=value) + questionary.Choice(title=f"{value}: {title}", value=value) for value, title in OPTS.items() ] - default = next(filter(lambda x: x.value == default_val, choices)) - version_provider: str = questionary.select( - "Choose the source of the version:", - choices=choices, - style=self.cz.style, - default=default, - ).unsafe_ask() - return version_provider + + return str( + questionary.select( + "Choose the source of the version:", + choices=choices, + style=self.cz.style, + default=self._version_provider_default_val, + ).unsafe_ask() + ) + + @property + def _version_provider_default_val(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 = "semver" - if self.project_info.is_python: - default = "pep440" - scheme: str = questionary.select( - "Choose version scheme: ", - choices=list(KNOWN_SCHEMES), - style=self.cz.style, - default=default, - ).unsafe_ask() - return scheme + default = "pep440" if self.project_info.is_python else "semver" + + return str( + questionary.select( + "Choose version scheme: ", + choices=list(KNOWN_SCHEMES), + style=self.cz.style, + default=default, + ).unsafe_ask() + ) def _ask_major_version_zero(self, version: Version) -> bool: """Ask for setting: major_version_zero""" - if version.major > 0: - return False - major_version_zero: bool = questionary.confirm( - "Keep major version zero (0.x) during breaking changes", - default=True, - auto_enter=True, - ).unsafe_ask() - return major_version_zero + return ( + version.major <= 0 + and questionary.confirm( + "Keep major version zero (0.x) during breaking changes", + default=True, + auto_enter=True, + ).unsafe_ask() + ) def _ask_update_changelog_on_bump(self) -> bool: "Ask for setting: update_changelog_on_bump" - update_changelog_on_bump: bool = questionary.confirm( - "Create changelog automatically on bump", - default=True, - auto_enter=True, - ).unsafe_ask() - return update_changelog_on_bump + return bool( + questionary.confirm( + "Create changelog automatically on bump", + default=True, + auto_enter=True, + ).unsafe_ask() + ) def _exec_install_pre_commit_hook(self, hook_types: list[str]): cmd_str = self._gen_pre_commit_cmd(hook_types) @@ -318,14 +297,49 @@ 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.") - cmd_str = "pre-commit install " + " ".join( - f"--hook-type {ty}" for ty in hook_types - ) - return cmd_str + hook_str = " ".join(f"--hook-type {ty}" for ty in hook_types) + return f"pre-commit install {hook_str}" + + def _init_config(self, config_path: str): + 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) + + def _init_pre_commit_hook(self): + 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() + try: + self._install_pre_commit_hook(hook_types) + except InitFailedError as e: + raise InitFailedError(f"Failed to install pre-commit hook.\n{e}") + + def _install_pre_commit_hook(self, hook_types: list[str] | None): + if not hook_types: + return + + config_data = self._read_pre_commit_config() + + with smart_open( + self._PRE_COMMIT_CONFIG_FILENAME, "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.") + + self._exec_install_pre_commit_hook(hook_types) + out.write("commitizen pre-commit hook is now installed in your '.git'\n") - def _install_pre_commit_hook(self, hook_types: list[str] | None = None): - pre_commit_config_filename = ".pre-commit-config.yaml" - cz_hook_config = { + def _read_pre_commit_config(self) -> dict[Any, Any]: + CZ_HOOK_CONFIG = { "repo": "https://github.com/commitizen-tools/commitizen", "rev": f"v{__version__}", "hooks": [ @@ -334,41 +348,37 @@ def _install_pre_commit_hook(self, hook_types: list[str] | None = None): ], } - config_data = {} + DEFAULT_CONFIG = {"repos": [CZ_HOOK_CONFIG]} + if not self.project_info.has_pre_commit_config: - # .pre-commit-config.yaml does not exist - config_data["repos"] = [cz_hook_config] - else: - with open( - pre_commit_config_filename, encoding=self.encoding - ) as config_file: - yaml_data = yaml.safe_load(config_file) - if yaml_data: - config_data = yaml_data - - if "repos" in config_data: - for pre_commit_hook in config_data["repos"]: - if "commitizen" in pre_commit_hook["repo"]: - out.write("commitizen already in pre-commit config") - break - else: - config_data["repos"].append(cz_hook_config) - else: - # .pre-commit-config.yaml exists but there's no "repos" key - config_data["repos"] = [cz_hook_config] + return DEFAULT_CONFIG - with smart_open( - pre_commit_config_filename, "w", encoding=self.encoding + with open( + self._PRE_COMMIT_CONFIG_FILENAME, encoding=self.encoding ) as config_file: - yaml.safe_dump(config_data, stream=config_file) + config_data = yaml.safe_load(config_file) + if not isinstance(config_data, dict): + return DEFAULT_CONFIG - 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") + repos = config_data.get("repos") + if not repos: + return DEFAULT_CONFIG + + if any("commitizen" in hook["repo"] for hook in repos): + out.write("commitizen already in pre-commit config") + else: + config_data["repos"].append(CZ_HOOK_CONFIG) + + return config_data - def _update_config_file(self, values: dict[str, Any]): - for key, value in values.items(): - self.config.set_key(key, value) + def _update_config_file( + self, version_provider: str, version: VersionScheme, **kwargs + ): + for key, value in kwargs.items(): + if value: + self.config.set_key(key, value) + + if version_provider == "commitizen": + self.config.set_key("version", version.public) + else: + self.config.set_key("version_provider", version_provider) diff --git a/commitizen/git.py b/commitizen/git.py index 19ca46b6c..086837458 100644 --- a/commitizen/git.py +++ b/commitizen/git.py @@ -231,7 +231,7 @@ def get_tag_message(tag: str) -> str | None: return c.out.strip() -def get_tag_names() -> list[str | None]: +def get_tag_names() -> list[str]: c = cmd.run("git tag --list") if c.err: return [] diff --git a/tests/commands/test_init_command.py b/tests/commands/test_init_command.py index f617c51d8..bf198627d 100644 --- a/tests/commands/test_init_command.py +++ b/tests/commands/test_init_command.py @@ -26,8 +26,8 @@ def unsafe_ask(self): return self.expected_return -pre_commit_config_filename = ".pre-commit-config.yaml" -cz_hook_config = { +PRE_COMMIT_CONFIG_FILENAME = ".pre-commit-config.yaml" +CZ_HOOK_CONFIG = { "repo": "https://github.com/commitizen-tools/commitizen", "rev": f"v{__version__}", "hooks": [ @@ -36,14 +36,14 @@ def unsafe_ask(self): ], } -expected_config = ( +EXPECTED_CONFIG = ( "[tool.commitizen]\n" 'name = "cz_conventional_commits"\n' 'tag_format = "$version"\n' 'version_scheme = "semver"\n' - 'version = "0.0.1"\n' "update_changelog_on_bump = true\n" "major_version_zero = true\n" + 'version = "0.0.1"\n' ) EXPECTED_DICT_CONFIG = { @@ -77,10 +77,9 @@ def test_init_without_setup_pre_commit_hook(tmpdir, mocker: MockFixture, config) commands.Init(config)() with open("pyproject.toml", encoding="utf-8") as toml_file: - config_data = toml_file.read() - assert config_data == expected_config + assert EXPECTED_CONFIG == toml_file.read() - assert not os.path.isfile(pre_commit_config_filename) + assert not os.path.isfile(PRE_COMMIT_CONFIG_FILENAME) def test_init_when_config_already_exists(config, capsys): @@ -169,16 +168,15 @@ def check_cz_config(config: str): assert yaml.load(file, Loader=yaml.FullLoader) == EXPECTED_DICT_CONFIG else: config_data = file.read() - assert config_data == expected_config + assert config_data == EXPECTED_CONFIG def check_pre_commit_config(expected: list[dict[str, Any]]): """ Check the content of pre-commit config is as expected """ - with open(pre_commit_config_filename) as pre_commit_file: - pre_commit_config_data = yaml.safe_load(pre_commit_file.read()) - assert pre_commit_config_data == {"repos": expected} + with open(PRE_COMMIT_CONFIG_FILENAME) as pre_commit_file: + assert {"repos": expected} == yaml.safe_load(pre_commit_file.read()) @pytest.mark.usefixtures("pre_commit_installed") @@ -187,16 +185,16 @@ def test_no_existing_pre_commit_config(_, default_choice, tmpdir, config): with tmpdir.as_cwd(): commands.Init(config)() check_cz_config(default_choice) - check_pre_commit_config([cz_hook_config]) + check_pre_commit_config([CZ_HOOK_CONFIG]) def test_empty_pre_commit_config(_, default_choice, tmpdir, config): with tmpdir.as_cwd(): - p = tmpdir.join(pre_commit_config_filename) + p = tmpdir.join(PRE_COMMIT_CONFIG_FILENAME) p.write("") commands.Init(config)() check_cz_config(default_choice) - check_pre_commit_config([cz_hook_config]) + check_pre_commit_config([CZ_HOOK_CONFIG]) def test_pre_commit_config_without_cz_hook(_, default_choice, tmpdir, config): existing_hook_config = { @@ -206,22 +204,22 @@ def test_pre_commit_config_without_cz_hook(_, default_choice, tmpdir, config): } with tmpdir.as_cwd(): - p = tmpdir.join(pre_commit_config_filename) + p = tmpdir.join(PRE_COMMIT_CONFIG_FILENAME) p.write(yaml.safe_dump({"repos": [existing_hook_config]})) commands.Init(config)() check_cz_config(default_choice) - check_pre_commit_config([existing_hook_config, cz_hook_config]) + check_pre_commit_config([existing_hook_config, CZ_HOOK_CONFIG]) def test_cz_hook_exists_in_pre_commit_config(_, default_choice, tmpdir, config): with tmpdir.as_cwd(): - p = tmpdir.join(pre_commit_config_filename) - p.write(yaml.safe_dump({"repos": [cz_hook_config]})) + p = tmpdir.join(PRE_COMMIT_CONFIG_FILENAME) + p.write(yaml.safe_dump({"repos": [CZ_HOOK_CONFIG]})) commands.Init(config)() check_cz_config(default_choice) # check that config is not duplicated - check_pre_commit_config([cz_hook_config]) + check_pre_commit_config([CZ_HOOK_CONFIG]) class TestNoPreCommitInstalled: @@ -266,3 +264,198 @@ def test_init_command_shows_description_when_use_help_option( out, _ = capsys.readouterr() file_regression.check(out, extension=".txt") + + +def test_init_with_confirmed_tag_format(config, mocker: MockFixture, tmpdir): + mocker.patch( + "commitizen.commands.init.get_tag_names", return_value=["v0.0.2", "v0.0.1"] + ) + mocker.patch("commitizen.commands.init.get_latest_tag_name", return_value="v0.0.2") + mocker.patch( + "questionary.select", + side_effect=[ + FakeQuestion("pyproject.toml"), + FakeQuestion("cz_conventional_commits"), + FakeQuestion("commitizen"), + FakeQuestion("semver"), + ], + ) + mocker.patch("questionary.confirm", return_value=FakeQuestion(True)) + mocker.patch("questionary.text", return_value=FakeQuestion("$version")) + mocker.patch("questionary.checkbox", return_value=FakeQuestion(None)) + + with tmpdir.as_cwd(): + commands.Init(config)() + with open("pyproject.toml", encoding="utf-8") as toml_file: + assert 'tag_format = "v$version"' in toml_file.read() + + +def test_init_with_no_existing_tags(config, mocker: MockFixture, tmpdir): + mocker.patch("commitizen.commands.init.get_tag_names", return_value=[]) + mocker.patch("commitizen.commands.init.get_latest_tag_name", return_value="v1.0.0") + mocker.patch( + "questionary.select", + side_effect=[ + FakeQuestion("pyproject.toml"), + FakeQuestion("cz_conventional_commits"), + FakeQuestion("commitizen"), + FakeQuestion("semver"), + ], + ) + mocker.patch("questionary.confirm", return_value=FakeQuestion(False)) + mocker.patch("questionary.text", return_value=FakeQuestion("$version")) + mocker.patch("questionary.checkbox", return_value=FakeQuestion(None)) + + with tmpdir.as_cwd(): + commands.Init(config)() + with open("pyproject.toml", encoding="utf-8") as toml_file: + assert 'version = "0.0.1"' in toml_file.read() + + +def test_init_with_no_existing_latest_tag(config, mocker: MockFixture, tmpdir): + mocker.patch("commitizen.commands.init.get_latest_tag_name", return_value=None) + mocker.patch( + "questionary.select", + side_effect=[ + FakeQuestion("pyproject.toml"), + FakeQuestion("cz_conventional_commits"), + FakeQuestion("commitizen"), + FakeQuestion("semver"), + ], + ) + mocker.patch("questionary.confirm", return_value=FakeQuestion(True)) + mocker.patch("questionary.text", return_value=FakeQuestion("$version")) + mocker.patch("questionary.checkbox", return_value=FakeQuestion(None)) + + with tmpdir.as_cwd(): + commands.Init(config)() + with open("pyproject.toml", encoding="utf-8") as toml_file: + assert 'version = "0.0.1"' in toml_file.read() + + +def test_init_with_existing_tags(config, mocker: MockFixture, tmpdir): + expected_tags = ["v1.0.0", "v0.9.0", "v0.8.0"] + mocker.patch("commitizen.commands.init.get_tag_names", return_value=expected_tags) + mocker.patch("commitizen.commands.init.get_latest_tag_name", return_value="v1.0.0") + mocker.patch( + "questionary.select", + side_effect=[ + FakeQuestion("pyproject.toml"), + FakeQuestion("cz_conventional_commits"), + FakeQuestion("commitizen"), + FakeQuestion("semver"), # Select version scheme first + FakeQuestion("v1.0.0"), # Then select the latest tag + ], + ) + mocker.patch("questionary.confirm", return_value=FakeQuestion(True)) + mocker.patch("questionary.text", return_value=FakeQuestion("$version")) + mocker.patch("questionary.checkbox", return_value=FakeQuestion(None)) + + with tmpdir.as_cwd(): + commands.Init(config)() + with open("pyproject.toml", encoding="utf-8") as toml_file: + assert 'version = "1.0.0"' in toml_file.read() + + +def test_init_with_non_commitizen_version_provider(config, mocker: MockFixture, tmpdir): + mocker.patch( + "questionary.select", + side_effect=[ + FakeQuestion("pyproject.toml"), + FakeQuestion("cz_conventional_commits"), + FakeQuestion("pep621"), # Select a non-commitizen version provider + FakeQuestion("semver"), + ], + ) + mocker.patch("questionary.confirm", return_value=FakeQuestion(True)) + mocker.patch("questionary.text", return_value=FakeQuestion("$version")) + mocker.patch("questionary.checkbox", return_value=FakeQuestion(None)) + + with tmpdir.as_cwd(): + commands.Init(config)() + with open("pyproject.toml", encoding="utf-8") as toml_file: + content = toml_file.read() + assert 'version_provider = "pep621"' in content + assert ( + 'version = "0.0.1"' not in content + ) # Version should not be set for non-commitizen providers + + +class TestVersionProviderDefault: + def test_default_for_python_poetry(self, config, mocker: MockFixture): + mock_project_info = mocker.Mock() + mock_project_info.is_python = True + mock_project_info.is_python_poetry = True + mock_project_info.is_python_uv = False + mocker.patch( + "commitizen.commands.init.ProjectInfo", return_value=mock_project_info + ) + init = commands.Init(config) + assert init._version_provider_default_val == "poetry" + + def test_default_for_python_uv(self, config, mocker: MockFixture): + mock_project_info = mocker.Mock() + mock_project_info.is_python = True + mock_project_info.is_python_poetry = False + mock_project_info.is_python_uv = True + mocker.patch( + "commitizen.commands.init.ProjectInfo", return_value=mock_project_info + ) + init = commands.Init(config) + assert init._version_provider_default_val == "uv" + + def test_default_for_python_pep621(self, config, mocker: MockFixture): + mock_project_info = mocker.Mock() + mock_project_info.is_python = True + mock_project_info.is_python_poetry = False + mock_project_info.is_python_uv = False + mocker.patch( + "commitizen.commands.init.ProjectInfo", return_value=mock_project_info + ) + init = commands.Init(config) + assert init._version_provider_default_val == "pep621" + + def test_default_for_rust_cargo(self, config, mocker: MockFixture): + mock_project_info = mocker.Mock() + mock_project_info.is_python = False + mock_project_info.is_rust_cargo = True + mocker.patch( + "commitizen.commands.init.ProjectInfo", return_value=mock_project_info + ) + init = commands.Init(config) + assert init._version_provider_default_val == "cargo" + + def test_default_for_npm_package(self, config, mocker: MockFixture): + mock_project_info = mocker.Mock() + mock_project_info.is_python = False + mock_project_info.is_rust_cargo = False + mock_project_info.is_npm_package = True + mocker.patch( + "commitizen.commands.init.ProjectInfo", return_value=mock_project_info + ) + init = commands.Init(config) + assert init._version_provider_default_val == "npm" + + def test_default_for_php_composer(self, config, mocker: MockFixture): + mock_project_info = mocker.Mock() + mock_project_info.is_python = False + mock_project_info.is_rust_cargo = False + mock_project_info.is_npm_package = False + mock_project_info.is_php_composer = True + mocker.patch( + "commitizen.commands.init.ProjectInfo", return_value=mock_project_info + ) + init = commands.Init(config) + assert init._version_provider_default_val == "composer" + + def test_default_fallback_to_commitizen(self, config, mocker: MockFixture): + mock_project_info = mocker.Mock() + mock_project_info.is_python = False + mock_project_info.is_rust_cargo = False + mock_project_info.is_npm_package = False + mock_project_info.is_php_composer = False + mocker.patch( + "commitizen.commands.init.ProjectInfo", return_value=mock_project_info + ) + init = commands.Init(config) + assert init._version_provider_default_val == "commitizen" diff --git a/tests/test_git.py b/tests/test_git.py index 8b2fc2b86..81f2dc6ce 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -269,9 +269,9 @@ def test_get_commits_with_signature(): def test_get_tag_names_has_correct_arrow_annotation(): - arrow_annotation = inspect.getfullargspec(git.get_tag_names).annotations["return"] - - assert arrow_annotation == "list[str | None]" + assert ( + "list[str]" == inspect.getfullargspec(git.get_tag_names).annotations["return"] + ) def test_get_latest_tag_name(tmp_commitizen_project): From 9fa878c850e8845ab71b1881752c1ba29ac8ff00 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Sat, 17 May 2025 15:45:03 +0800 Subject: [PATCH 2/4] test(init): correctly mock is_pre_commit_installed --- tests/commands/test_init_command.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/commands/test_init_command.py b/tests/commands/test_init_command.py index bf198627d..7c2839c0d 100644 --- a/tests/commands/test_init_command.py +++ b/tests/commands/test_init_command.py @@ -4,6 +4,7 @@ import os import sys from typing import Any +from unittest import mock import pytest import yaml @@ -125,6 +126,7 @@ def pre_commit_installed(mocker: MockFixture): # Assume the `pre-commit` is installed mocker.patch( "commitizen.commands.init.ProjectInfo.is_pre_commit_installed", + new_callable=mock.PropertyMock, return_value=True, ) # And installation success (i.e. no exception raised) @@ -229,6 +231,7 @@ def test_pre_commit_not_installed( # Assume `pre-commit` is not installed mocker.patch( "commitizen.commands.init.ProjectInfo.is_pre_commit_installed", + new_callable=mock.PropertyMock, return_value=False, ) with tmpdir.as_cwd(): @@ -241,6 +244,7 @@ def test_pre_commit_exec_failed( # Assume `pre-commit` is installed mocker.patch( "commitizen.commands.init.ProjectInfo.is_pre_commit_installed", + new_callable=mock.PropertyMock, return_value=True, ) # But pre-commit installation will fail From 62aad910123fe3c38282ed62917f43b0d1a26556 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Sat, 17 May 2025 15:55:07 +0800 Subject: [PATCH 3/4] test(init): add yaml without repos key --- commitizen/commands/init.py | 4 ++-- tests/commands/test_init_command.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/commitizen/commands/init.py b/commitizen/commands/init.py index 6475fc069..8f28b0dfe 100644 --- a/commitizen/commands/init.py +++ b/commitizen/commands/init.py @@ -366,9 +366,9 @@ def _read_pre_commit_config(self) -> dict[Any, Any]: if any("commitizen" in hook["repo"] for hook in repos): out.write("commitizen already in pre-commit config") - else: - config_data["repos"].append(CZ_HOOK_CONFIG) + return config_data + config_data["repos"].append(CZ_HOOK_CONFIG) return config_data def _update_config_file( diff --git a/tests/commands/test_init_command.py b/tests/commands/test_init_command.py index 7c2839c0d..196b679b3 100644 --- a/tests/commands/test_init_command.py +++ b/tests/commands/test_init_command.py @@ -213,6 +213,19 @@ def test_pre_commit_config_without_cz_hook(_, default_choice, tmpdir, config): check_cz_config(default_choice) check_pre_commit_config([existing_hook_config, CZ_HOOK_CONFIG]) + def test_pre_commit_config_yaml_without_repos(_, default_choice, tmpdir, config): + with tmpdir.as_cwd(): + # Write a dictionary YAML content without 'repos' key + p = tmpdir.join(PRE_COMMIT_CONFIG_FILENAME) + p.write( + yaml.safe_dump({"some_other_key": "value"}) + ) # Dictionary without 'repos' key + + commands.Init(config)() + check_cz_config(default_choice) + # Should use DEFAULT_CONFIG since the file content doesn't have 'repos' key + check_pre_commit_config([CZ_HOOK_CONFIG]) + def test_cz_hook_exists_in_pre_commit_config(_, default_choice, tmpdir, config): with tmpdir.as_cwd(): p = tmpdir.join(PRE_COMMIT_CONFIG_FILENAME) From c028883dc116daf1b4ce081ccd127adaa07929b8 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Sat, 17 May 2025 15:57:21 +0800 Subject: [PATCH 4/4] test(init): add yaml not a dict test case --- tests/commands/test_init_command.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/commands/test_init_command.py b/tests/commands/test_init_command.py index 196b679b3..d1e07851a 100644 --- a/tests/commands/test_init_command.py +++ b/tests/commands/test_init_command.py @@ -226,6 +226,19 @@ def test_pre_commit_config_yaml_without_repos(_, default_choice, tmpdir, config) # Should use DEFAULT_CONFIG since the file content doesn't have 'repos' key check_pre_commit_config([CZ_HOOK_CONFIG]) + def test_pre_commit_config_yaml_not_a_dict(_, default_choice, tmpdir, config): + with tmpdir.as_cwd(): + # Write a dictionary YAML content without 'repos' key + p = tmpdir.join(PRE_COMMIT_CONFIG_FILENAME) + p.write( + yaml.safe_dump(["item1", "item2"]) + ) # Dictionary without 'repos' key + + commands.Init(config)() + check_cz_config(default_choice) + # Should use DEFAULT_CONFIG since the file content doesn't have 'repos' key + check_pre_commit_config([CZ_HOOK_CONFIG]) + def test_cz_hook_exists_in_pre_commit_config(_, default_choice, tmpdir, config): with tmpdir.as_cwd(): p = tmpdir.join(PRE_COMMIT_CONFIG_FILENAME)