diff --git a/commitizen/commands/init.py b/commitizen/commands/init.py index 2ce3981f4..34540f138 100644 --- a/commitizen/commands/init.py +++ b/commitizen/commands/init.py @@ -1,13 +1,11 @@ from __future__ import annotations -import os -import shutil 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 +63,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 +70,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: @@ -195,14 +141,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 @@ -267,37 +209,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,7 +273,7 @@ def _get_config_data(self) -> dict[str, Any]: ], } - if not self.project_info.has_pre_commit_config: + if not project_info.has_pre_commit_config(): # .pre-commit-config.yaml does not exist return {"repos": [CZ_HOOK_CONFIG]} @@ -377,7 +299,7 @@ def _install_pre_commit_hook(self, hook_types: list[str] | None = None) -> 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("pre-commit is not installed in current environment.") if hook_types is None: hook_types = ["commit-msg", "pre-push"] diff --git a/commitizen/project_info.py b/commitizen/project_info.py new file mode 100644 index 000000000..02d4e8164 --- /dev/null +++ b/commitizen/project_info.py @@ -0,0 +1,75 @@ +"""Resolves project information about the current working directory.""" + +import shutil +from pathlib import Path +from typing import Literal + + +def has_pyproject() -> bool: + return Path("pyproject.toml").is_file() + + +def has_uv_lock() -> bool: + return Path("uv.lock").is_file() + + +def has_setup() -> bool: + return Path("setup.py").is_file() + + +def has_pre_commit_config() -> bool: + return Path(".pre-commit-config.yaml").is_file() + + +def is_python_uv() -> bool: + return has_pyproject() and has_uv_lock() + + +def is_python_poetry() -> bool: + return has_pyproject() and "[tool.poetry]" in Path("pyproject.toml").read_text() + + +def is_python() -> bool: + return has_pyproject() or has_setup() + + +def is_rust_cargo() -> bool: + return Path("Cargo.toml").is_file() + + +def is_npm_package() -> bool: + return Path("package.json").is_file() + + +def is_php_composer() -> bool: + return Path("composer.json").is_file() + + +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" +]: + if is_python(): + if is_python_poetry(): + return "poetry" + if is_python_uv(): + return "uv" + return "pep621" + if is_rust_cargo(): + return "cargo" + if is_npm_package(): + return "npm" + if is_php_composer(): + return "composer" + return "commitizen" + + +def get_default_config_filename() -> Literal["pyproject.toml", ".cz.toml"]: + return "pyproject.toml" if has_pyproject() else ".cz.toml" + + +def get_default_version_scheme() -> Literal["pep440", "semver"]: + return "pep440" if is_python() else "semver" diff --git a/tests/commands/test_init_command.py b/tests/commands/test_init_command.py index c156fb023..6857dd0a7 100644 --- a/tests/commands/test_init_command.py +++ b/tests/commands/test_init_command.py @@ -125,7 +125,7 @@ def test_executed_pre_commit_command(config): 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) @@ -230,7 +230,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(): @@ -242,7 +242,7 @@ def test_pre_commit_exec_failed( ): # Assume `pre-commit` is installed mocker.patch( - "commitizen.commands.init.ProjectInfo.is_pre_commit_installed", + "commitizen.project_info.is_pre_commit_installed", return_value=True, ) # But pre-commit installation will fail diff --git a/tests/test_project_info.py b/tests/test_project_info.py new file mode 100644 index 000000000..21832dcb0 --- /dev/null +++ b/tests/test_project_info.py @@ -0,0 +1,287 @@ +"""Tests for project_info module.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from commitizen import project_info + + +class TestFileDetection: + """Test file detection functions.""" + + def test_has_pyproject_when_file_exists(self, chdir): + """Test has_pyproject returns True when pyproject.toml exists.""" + Path("pyproject.toml").touch() + assert project_info.has_pyproject() is True + + def test_has_pyproject_when_file_not_exists(self, chdir): + """Test has_pyproject returns False when pyproject.toml doesn't exist.""" + assert project_info.has_pyproject() is False + + def test_has_uv_lock_when_file_exists(self, chdir): + """Test has_uv_lock returns True when uv.lock exists.""" + Path("uv.lock").touch() + assert project_info.has_uv_lock() is True + + def test_has_uv_lock_when_file_not_exists(self, chdir): + """Test has_uv_lock returns False when uv.lock doesn't exist.""" + assert project_info.has_uv_lock() is False + + def test_has_setup_when_file_exists(self, chdir): + """Test has_setup returns True when setup.py exists.""" + Path("setup.py").touch() + assert project_info.has_setup() is True + + def test_has_setup_when_file_not_exists(self, chdir): + """Test has_setup returns False when setup.py doesn't exist.""" + assert project_info.has_setup() is False + + def test_has_pre_commit_config_when_file_exists(self, chdir): + """Test has_pre_commit_config returns True when .pre-commit-config.yaml exists.""" + Path(".pre-commit-config.yaml").touch() + assert project_info.has_pre_commit_config() is True + + def test_has_pre_commit_config_when_file_not_exists(self, chdir): + """Test has_pre_commit_config returns False when .pre-commit-config.yaml doesn't exist.""" + assert project_info.has_pre_commit_config() is False + + +class TestProjectTypeDetection: + """Test project type detection functions.""" + + def test_is_python_uv_when_both_files_exist(self, chdir): + """Test is_python_uv returns True when both pyproject.toml and uv.lock exist.""" + Path("pyproject.toml").touch() + Path("uv.lock").touch() + assert project_info.is_python_uv() is True + + def test_is_python_uv_when_pyproject_missing(self, chdir): + """Test is_python_uv returns False when pyproject.toml is missing.""" + Path("uv.lock").touch() + assert project_info.is_python_uv() is False + + def test_is_python_uv_when_uv_lock_missing(self, chdir): + """Test is_python_uv returns False when uv.lock is missing.""" + Path("pyproject.toml").touch() + assert project_info.is_python_uv() is False + + def test_is_python_poetry_when_poetry_section_exists(self, chdir): + """Test is_python_poetry returns True when pyproject.toml contains [tool.poetry].""" + pyproject_content = '[tool.poetry]\nname = "test"\nversion = "0.1.0"' + Path("pyproject.toml").write_text(pyproject_content) + assert project_info.is_python_poetry() is True + + def test_is_python_poetry_when_poetry_section_missing(self, chdir): + """Test is_python_poetry returns False when pyproject.toml doesn't contain [tool.poetry].""" + pyproject_content = '[tool.commitizen]\nversion = "0.1.0"' + Path("pyproject.toml").write_text(pyproject_content) + assert project_info.is_python_poetry() is False + + def test_is_python_poetry_when_pyproject_missing(self, chdir): + """Test is_python_poetry returns False when pyproject.toml doesn't exist.""" + assert project_info.is_python_poetry() is False + + def test_is_python_when_pyproject_exists(self, chdir): + """Test is_python returns True when pyproject.toml exists.""" + Path("pyproject.toml").touch() + assert project_info.is_python() is True + + def test_is_python_when_setup_exists(self, chdir): + """Test is_python returns True when setup.py exists.""" + Path("setup.py").touch() + assert project_info.is_python() is True + + def test_is_python_when_both_exist(self, chdir): + """Test is_python returns True when both pyproject.toml and setup.py exist.""" + Path("pyproject.toml").touch() + Path("setup.py").touch() + assert project_info.is_python() is True + + def test_is_python_when_neither_exists(self, chdir): + """Test is_python returns False when neither pyproject.toml nor setup.py exist.""" + assert project_info.is_python() is False + + def test_is_rust_cargo_when_file_exists(self, chdir): + """Test is_rust_cargo returns True when Cargo.toml exists.""" + Path("Cargo.toml").touch() + assert project_info.is_rust_cargo() is True + + def test_is_rust_cargo_when_file_not_exists(self, chdir): + """Test is_rust_cargo returns False when Cargo.toml doesn't exist.""" + assert project_info.is_rust_cargo() is False + + def test_is_npm_package_when_file_exists(self, chdir): + """Test is_npm_package returns True when package.json exists.""" + Path("package.json").touch() + assert project_info.is_npm_package() is True + + def test_is_npm_package_when_file_not_exists(self, chdir): + """Test is_npm_package returns False when package.json doesn't exist.""" + assert project_info.is_npm_package() is False + + def test_is_php_composer_when_file_exists(self, chdir): + """Test is_php_composer returns True when composer.json exists.""" + Path("composer.json").touch() + assert project_info.is_php_composer() is True + + def test_is_php_composer_when_file_not_exists(self, chdir): + """Test is_php_composer returns False when composer.json doesn't exist.""" + assert project_info.is_php_composer() is False + + +class TestPreCommitDetection: + """Test pre-commit installation detection.""" + + def test_is_pre_commit_installed_when_available(self, mocker): + """Test is_pre_commit_installed returns True when pre-commit is available.""" + mocker.patch("shutil.which", return_value="/usr/local/bin/pre-commit") + assert project_info.is_pre_commit_installed() is True + + def test_is_pre_commit_installed_when_not_available(self, mocker): + """Test is_pre_commit_installed returns False when pre-commit is not available.""" + mocker.patch("shutil.which", return_value=None) + assert project_info.is_pre_commit_installed() is False + + def test_is_pre_commit_installed_when_empty_string(self, mocker): + """Test is_pre_commit_installed returns False when shutil.which returns empty string.""" + mocker.patch("shutil.which", return_value="") + assert project_info.is_pre_commit_installed() is False + + +class TestDefaultVersionProvider: + """Test default version provider selection.""" + + def test_get_default_version_provider_python_poetry(self, chdir): + """Test get_default_version_provider returns 'poetry' for Python Poetry projects.""" + pyproject_content = '[tool.poetry]\nname = "test"\nversion = "0.1.0"' + Path("pyproject.toml").write_text(pyproject_content) + assert project_info.get_default_version_provider() == "poetry" + + def test_get_default_version_provider_python_uv(self, chdir): + """Test get_default_version_provider returns 'uv' for Python UV projects.""" + Path("pyproject.toml").touch() + Path("uv.lock").touch() + assert project_info.get_default_version_provider() == "uv" + + def test_get_default_version_provider_python_pep621(self, chdir): + """Test get_default_version_provider returns 'pep621' for Python projects without Poetry/UV.""" + pyproject_content = '[tool.commitizen]\nversion = "0.1.0"' + Path("pyproject.toml").write_text(pyproject_content) + assert project_info.get_default_version_provider() == "pep621" + + def test_get_default_version_provider_python_setup(self, chdir): + """Test get_default_version_provider returns 'pep621' for Python projects with setup.py.""" + Path("setup.py").touch() + assert project_info.get_default_version_provider() == "pep621" + + def test_get_default_version_provider_rust_cargo(self, chdir): + """Test get_default_version_provider returns 'cargo' for Rust projects.""" + Path("Cargo.toml").touch() + assert project_info.get_default_version_provider() == "cargo" + + def test_get_default_version_provider_npm(self, chdir): + """Test get_default_version_provider returns 'npm' for Node.js projects.""" + Path("package.json").touch() + assert project_info.get_default_version_provider() == "npm" + + def test_get_default_version_provider_php_composer(self, chdir): + """Test get_default_version_provider returns 'composer' for PHP projects.""" + Path("composer.json").touch() + assert project_info.get_default_version_provider() == "composer" + + def test_get_default_version_provider_commitizen_default(self, chdir): + """Test get_default_version_provider returns 'commitizen' as default.""" + # No project files present + assert project_info.get_default_version_provider() == "commitizen" + + def test_get_default_version_provider_priority_order(self, chdir): + """Test that Python projects take priority over other project types.""" + # Create files for multiple project types + Path("pyproject.toml").touch() + Path("Cargo.toml").touch() + Path("package.json").touch() + Path("composer.json").touch() + + # Should return Python provider, not others + assert project_info.get_default_version_provider() == "pep621" + + +class TestDefaultConfigFilename: + """Test default config filename selection.""" + + def test_get_default_config_filename_pyproject(self, chdir): + """Test get_default_config_filename returns 'pyproject.toml' when it exists.""" + Path("pyproject.toml").touch() + assert project_info.get_default_config_filename() == "pyproject.toml" + + def test_get_default_config_filename_cz_toml(self, chdir): + """Test get_default_config_filename returns '.cz.toml' when pyproject.toml doesn't exist.""" + assert project_info.get_default_config_filename() == ".cz.toml" + + +class TestDefaultVersionScheme: + """Test default version scheme selection.""" + + def test_get_default_version_scheme_python_pep440(self, chdir): + """Test get_default_version_scheme returns 'pep440' for Python projects.""" + Path("pyproject.toml").touch() + assert project_info.get_default_version_scheme() == "pep440" + + def test_get_default_version_scheme_python_setup(self, chdir): + """Test get_default_version_scheme returns 'pep440' for Python projects with setup.py.""" + Path("setup.py").touch() + assert project_info.get_default_version_scheme() == "pep440" + + def test_get_default_version_scheme_non_python_semver(self, chdir): + """Test get_default_version_scheme returns 'semver' for non-Python projects.""" + Path("package.json").touch() + assert project_info.get_default_version_scheme() == "semver" + + def test_get_default_version_scheme_no_project_semver(self, chdir): + """Test get_default_version_scheme returns 'semver' when no project files exist.""" + assert project_info.get_default_version_scheme() == "semver" + + +class TestEdgeCases: + """Test edge cases and error conditions.""" + + def test_is_python_poetry_file_read_error(self, chdir, mocker): + """Test is_python_poetry propagates file read errors.""" + Path("pyproject.toml").touch() + # Mock Path.read_text to raise an exception + mocker.patch("pathlib.Path.read_text", side_effect=OSError("Permission denied")) + # The function should propagate the OSError + with pytest.raises(OSError, match="Permission denied"): + project_info.is_python_poetry() + + def test_get_default_version_provider_file_read_error(self, chdir, mocker): + """Test get_default_version_provider propagates file read errors.""" + Path("pyproject.toml").touch() + # Mock Path.read_text to raise an exception + mocker.patch("pathlib.Path.read_text", side_effect=OSError("Permission denied")) + # The function should propagate the OSError from is_python_poetry + with pytest.raises(OSError, match="Permission denied"): + project_info.get_default_version_provider() + + def test_poetry_detection_with_whitespace(self, chdir): + """Test poetry detection works with whitespace around the section name.""" + pyproject_content = ' [tool.poetry] \nname = "test"\nversion = "0.1.0"' + Path("pyproject.toml").write_text(pyproject_content) + assert project_info.is_python_poetry() is True + + def test_poetry_detection_with_comments(self, chdir): + """Test poetry detection works with comments in the file.""" + pyproject_content = ( + '# This is a comment\n[tool.poetry]\nname = "test"\nversion = "0.1.0"' + ) + Path("pyproject.toml").write_text(pyproject_content) + assert project_info.is_python_poetry() is True + + def test_poetry_detection_partial_match(self, chdir): + """Test poetry detection doesn't match partial section names.""" + pyproject_content = '[tool.poetry_extra]\nname = "test"\nversion = "0.1.0"' + Path("pyproject.toml").write_text(pyproject_content) + assert project_info.is_python_poetry() is False