diff --git a/commitizen/cli.py b/commitizen/cli.py index bebacdfe5..1f1b081d8 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -97,6 +97,10 @@ def __call__( "name": ["-n", "--name"], "help": "use the given commitizen (default: cz_conventional_commits)", }, + { + "name": ["-cf", "--config-file"], + "help": "configuration file path", + }, { "name": ["-nr", "--no-raise"], "type": str, @@ -505,7 +509,6 @@ def parse_no_raise(comma_separated_no_raise: str) -> list[int]: def main(): - conf = config.read_cfg() parser = cli(data) argcomplete.autocomplete(parser) @@ -547,6 +550,11 @@ def main(): extra_args = " ".join(unknown_args[1:]) arguments["extra_cli_args"] = extra_args + if args.config_file: + conf = config.read_cfg(cfg_path=Path(args.config_file)) + else: + conf = config.read_cfg() + if args.name: conf.update({"name": args.name}) elif not args.name and not conf.path: diff --git a/commitizen/config/__init__.py b/commitizen/config/__init__.py index 09e38ca96..0a3bdd8c8 100644 --- a/commitizen/config/__init__.py +++ b/commitizen/config/__init__.py @@ -3,6 +3,7 @@ from pathlib import Path from commitizen import defaults, git +from commitizen.exceptions import InvalidConfigurationError from .base_config import BaseConfig from .json_config import JsonConfig @@ -10,14 +11,65 @@ from .yaml_config import YAMLConfig -def read_cfg() -> BaseConfig: +def read_cfg( + cfg_path: Path | None = None, +) -> BaseConfig | TomlConfig | JsonConfig | YAMLConfig: + """ + Read and load a configuration based on the provided path or defaults. + + Args: + cfg_path (Path | None): The path to the configuration file passed from CLI args, + or None if no specific path is provided. Defaults to None. + + Returns: + BaseConfig: An instance of `BaseConfig` (included TomlConfig | JsonConfig | YAMLConfig) + containing the loaded configuration. + + Raises: + InvalidConfigurationError: If the specified configuration file path does not exist. + InvalidConfigurationError: If the loaded configuration is empty, indicating that + the file does not contain any valid configuration data. + + Note: + If `cfg_path` is provided, the function attempts to load the configuration from + that path. If the path does not exist or the configuration is empty, an exception + is raised. If `cfg_path` is not provided, the function searches for a configuration + in default locations using `_find_config_from_defaults()`. + """ conf = BaseConfig() + if cfg_path: + if not cfg_path.exists(): + raise InvalidConfigurationError(f"File {cfg_path} not exists.") + _conf = _load_config_from_file(cfg_path) + if _conf.is_empty_config: + raise InvalidConfigurationError( + f"File {cfg_path} doesn't contain any configuration. " + f"Fill it or don't use --config-file option." + ) + else: + _conf = _find_config_from_defaults() + + if _conf: + conf = _conf + + return conf + + +def _find_config_from_defaults(): + """ + Find and load a configuration from default search paths. + + This function looks for configuration files in default locations and loads the first + non-empty configuration it encounters. If no suitable configuration is found, it returns None. + + Returns: + TomlConfig | JsonConfig | YAMLConfig | None + """ git_project_root = git.find_git_project_root() cfg_search_paths = [Path(".")] if git_project_root: cfg_search_paths.append(git_project_root) - cfg_paths = ( path / Path(filename) for path in cfg_search_paths @@ -26,23 +78,48 @@ def read_cfg() -> BaseConfig: for filename in cfg_paths: if not filename.exists(): continue + _conf = _load_config_from_file(path=filename) + if not _conf.is_empty_config: + return _conf + return None - _conf: TomlConfig | JsonConfig | YAMLConfig - with open(filename, "rb") as f: - data: bytes = f.read() +def _load_config_from_file(path: Path) -> TomlConfig | JsonConfig | YAMLConfig: + """ + Load configuration data from a file based on its extension. - 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) + Args: + path (Path): The path to the configuration file. - if _conf.is_empty_config: - continue - else: - conf = _conf - break + Returns: + TomlConfig | JsonConfig | YAMLConfig: An instance of a configuration class + corresponding to the file's extension (TomlConfig for .toml, JsonConfig for .json, + or YAMLConfig for .yaml). - return conf + Raises: + ValueError: If the file extension is not one of the expected formats (toml, json, yaml). + + Note: + We expect that any object in defaults.py -> config_files + definitely falls under the conditions above. Therefore, + this error may occur when using an arbitrary configuration file path. + """ + _conf: TomlConfig | JsonConfig | YAMLConfig + + with open(path, "rb") as f: + data: bytes = f.read() + + if "toml" in path.suffix: + _conf = TomlConfig(data=data, path=path) + elif "json" in path.suffix: + _conf = JsonConfig(data=data, path=path) + elif "yaml" in path.suffix: + _conf = YAMLConfig(data=data, path=path) + else: + # We expect that any object in defaults.py -> config_files + # definitely falls under the conditions above. Therefore, + # this error may occur when using an arbitrary configuration file path. + raise InvalidConfigurationError( + "Config file should have a valid extension: toml, yaml or json" + ) + return _conf diff --git a/docs/README.md b/docs/README.md index e7a589f40..45e95c2c2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -100,7 +100,7 @@ Read more in the section [Getting Started](./getting_started.md). ```sh $ czp --help -usage: czp [-h] [--debug] [-n NAME] [-nr NO_RAISE] {init,commit,c,ls,example,info,schema,bump,changelog,ch,check,version} ... +usage: czp [-h] [--debug] [-n NAME] [-cf CONFIG_FILE] [-nr NO_RAISE] {init,commit,c,ls,example,info,schema,bump,changelog,ch,check,version} ... Commitizen is a cli tool to generate conventional commits. For more information about the topic go to https://conventionalcommits.org/ @@ -109,6 +109,8 @@ optional arguments: -h, --help show this help message and exit --debug use debug mode -n NAME, --name NAME use the given commitizen (default: cz_conventional_commits) + -cf CONFIG_FILE, --config-file CONFIG_FILE + configuration file path -nr NO_RAISE, --no-raise NO_RAISE comma separated error codes that won't rise error, e.g: czp -nr 1,2,3 bump. See codes at https://commitizen- tools.github.io/commitizen/exit_codes/ diff --git a/docs/config.md b/docs/config.md index 391c20f0f..dab993f80 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,5 +1,105 @@ # Configuration +## Configuration file + +### pyproject.toml or .cz.toml + +Default and recommended configuration format for a project. +For a **python** project, we recommend adding an entry to your `pyproject.toml`. +You can also create a `.cz.toml` file at the root of your project folder. + +Example configuration: + +```toml +[tool.commitizen] +name = "cz_conventional_commits" +version = "0.1.0" +version_files = [ + "src/__version__.py", + "pyproject.toml:version" +] +update_changelog_on_bump = true +style = [ + ["qmark", "fg:#ff9d00 bold"], + ["question", "bold"], + ["answer", "fg:#ff9d00 bold"], + ["pointer", "fg:#ff9d00 bold"], + ["highlighted", "fg:#ff9d00 bold"], + ["selected", "fg:#cc5454"], + ["separator", "fg:#cc5454"], + ["instruction", ""], + ["text", ""], + ["disabled", "fg:#858585 italic"] +] +``` + +### .cz.json or cz.json + +Commitizen has support for JSON configuration. Recommended for `NodeJS` projects. + +```json +{ + "commitizen": { + "name": "cz_conventional_commits", + "version": "0.1.0", + "version_files": ["src/__version__.py", "pyproject.toml:version"], + "style": [ + ["qmark", "fg:#ff9d00 bold"], + ["question", "bold"], + ["answer", "fg:#ff9d00 bold"], + ["pointer", "fg:#ff9d00 bold"], + ["highlighted", "fg:#ff9d00 bold"], + ["selected", "fg:#cc5454"], + ["separator", "fg:#cc5454"], + ["instruction", ""], + ["text", ""], + ["disabled", "fg:#858585 italic"] + ] + } +} +``` + +### .cz.yaml or cz.yaml + +YAML configuration is supported by Commitizen. Recommended for `Go`, `ansible`, or even `helm` charts projects. + +```yaml +commitizen: + name: cz_conventional_commits + version: 0.1.0 + version_files: + - src/__version__.py + - pyproject.toml:version + style: + - - qmark + - fg:#ff9d00 bold + - - question + - bold + - - answer + - fg:#ff9d00 bold + - - pointer + - fg:#ff9d00 bold + - - highlighted + - fg:#ff9d00 bold + - - selected + - fg:#cc5454 + - - separator + - fg:#cc5454 + - - instruction + - "" + - - text + - "" + - - disabled + - fg:#858585 italic +``` + +### Custom file path + +It is possible to specify custom path to the config file by using `-cf`/`--config-file` argument. +```cz -cf mics/cz.yaml``` + +Note: file should have a valid extension (toml/json/yaml). + ## Settings ### `name` @@ -216,99 +316,6 @@ Default: `{}` Provide extra variables to the changelog template. [Read more][template-customization] -## Configuration file - -### pyproject.toml or .cz.toml - -Default and recommended configuration format for a project. -For a **python** project, we recommend adding an entry to your `pyproject.toml`. -You can also create a `.cz.toml` file at the root of your project folder. - -Example configuration: - -```toml -[tool.commitizen] -name = "cz_conventional_commits" -version = "0.1.0" -version_files = [ - "src/__version__.py", - "pyproject.toml:version" -] -update_changelog_on_bump = true -style = [ - ["qmark", "fg:#ff9d00 bold"], - ["question", "bold"], - ["answer", "fg:#ff9d00 bold"], - ["pointer", "fg:#ff9d00 bold"], - ["highlighted", "fg:#ff9d00 bold"], - ["selected", "fg:#cc5454"], - ["separator", "fg:#cc5454"], - ["instruction", ""], - ["text", ""], - ["disabled", "fg:#858585 italic"] -] -``` - -### .cz.json or cz.json - -Commitizen has support for JSON configuration. Recommended for `NodeJS` projects. - -```json -{ - "commitizen": { - "name": "cz_conventional_commits", - "version": "0.1.0", - "version_files": ["src/__version__.py", "pyproject.toml:version"], - "style": [ - ["qmark", "fg:#ff9d00 bold"], - ["question", "bold"], - ["answer", "fg:#ff9d00 bold"], - ["pointer", "fg:#ff9d00 bold"], - ["highlighted", "fg:#ff9d00 bold"], - ["selected", "fg:#cc5454"], - ["separator", "fg:#cc5454"], - ["instruction", ""], - ["text", ""], - ["disabled", "fg:#858585 italic"] - ] - } -} -``` - -### .cz.yaml or cz.yaml - -YAML configuration is supported by Commitizen. Recommended for `Go`, `ansible`, or even `helm` charts projects. - -```yaml -commitizen: - name: cz_conventional_commits - version: 0.1.0 - version_files: - - src/__version__.py - - pyproject.toml:version - style: - - - qmark - - fg:#ff9d00 bold - - - question - - bold - - - answer - - fg:#ff9d00 bold - - - pointer - - fg:#ff9d00 bold - - - highlighted - - fg:#ff9d00 bold - - - selected - - fg:#cc5454 - - - separator - - fg:#cc5454 - - - instruction - - "" - - - text - - "" - - - disabled - - fg:#858585 italic -``` - ## Version providers Commitizen can read and write version from different sources. diff --git a/pyproject.toml b/pyproject.toml index 8ff83c5cb..8bc5e74fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,6 +136,7 @@ build-backend = "poetry.core.masonry.api" [tool.pytest.ini_options] addopts = "--strict-markers" +markers = ["czplus"] [tool.ruff] line-length = 88 diff --git a/tests/test_cli.py b/tests/test_cli.py index 93f6c16dd..883eab4ff 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,6 +12,7 @@ NoCommandFoundError, NotAGitProjectError, InvalidCommandArgumentError, + InvalidConfigurationError, ) @@ -172,3 +173,11 @@ def test_unknown_args_before_double_dash_raises(mocker: MockFixture): assert "Invalid commitizen arguments were found before -- separator" in str( excinfo.value ) + + +@pytest.mark.czplus +def test_pass_custom_config(mocker: MockFixture): + test_args = ["cz", "-cf", "file.toml", "example"] + mocker.patch.object(sys, "argv", test_args) + with pytest.raises(InvalidConfigurationError, match="File file.toml not exists."): + cli.main() diff --git a/tests/test_conf.py b/tests/test_conf.py index dcac8e015..d580d84ec 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -217,3 +217,35 @@ def test_init_with_invalid_content(self, tmpdir): with pytest.raises(InvalidConfigurationError, match=r"\.cz\.yaml"): config.YAMLConfig(data=existing_content, path=path) + + +@pytest.mark.czplus +class TestCustomFileConfig: + @pytest.fixture + def empty_files_manager(self, request, tmpdir): + with tmpdir.as_cwd(): + with open(request.param, "w", encoding="utf-8") as f: + f.write("") + yield + + def test_custom_file_not_exist(self): + with pytest.raises(InvalidConfigurationError, match=".*not exists.*"): + config.read_cfg(cfg_path=Path("file.yaml")) + + @pytest.mark.parametrize("empty_files_manager", ["file.toml"], indirect=True) + def test_custom_file_is_empty_config(self, empty_files_manager): + with pytest.raises(InvalidConfigurationError, match=".*Fill it.*"): + config.read_cfg(cfg_path=Path("file.toml")) + + @pytest.mark.parametrize("empty_files_manager", ["file.txt"], indirect=True) + def test_load_config_from_file_incorrect_extension(self, empty_files_manager): + assert Path("file.txt").exists() + with pytest.raises( + InvalidConfigurationError, match=".*should have a valid extension.*" + ): + config._load_config_from_file(path=Path("file.txt")) + + @pytest.mark.parametrize("config_files_manager", ["file.toml"], indirect=True) + def test_load_config_from_custom_file(self, config_files_manager): + cfg = config.read_cfg(cfg_path=Path("file.toml")) + assert cfg.settings == _settings