diff --git a/commitizen/commands/init.py b/commitizen/commands/init.py index 92e7d06d7..cd4021eb7 100644 --- a/commitizen/commands/init.py +++ b/commitizen/commands/init.py @@ -1,13 +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, JsonConfig, TomlConfig, YAMLConfig from commitizen.cz import registry @@ -65,57 +64,6 @@ 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" @@ -123,7 +71,6 @@ 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() def __call__(self) -> None: if self.config.path: @@ -167,7 +114,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." @@ -215,14 +162,10 @@ def __call__(self) -> None: out.success("Configuration complete 🚀") def _ask_config_path(self) -> str: - default_path = ( - "pyproject.toml" if self.project_info.has_pyproject else ".cz.toml" - ) - name: 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 name @@ -287,37 +230,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 @@ -351,8 +274,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(self._PRE_COMMIT_CONFIG_PATH, encoding=self.encoding) as config_file: 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 8c632a2b6..03061d20d 100644 --- a/tests/commands/test_init_command.py +++ b/tests/commands/test_init_command.py @@ -121,7 +121,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) @@ -231,7 +231,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