diff --git a/commitizen/config/base_config.py b/commitizen/config/base_config.py index f100cf995..e995b2357 100644 --- a/commitizen/config/base_config.py +++ b/commitizen/config/base_config.py @@ -1,12 +1,15 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any -from commitizen.defaults import DEFAULT_SETTINGS, Settings +from commitizen import out +from commitizen.defaults import DEFAULT_SETTINGS, KNOWN_SETTINGS_KEYS, Settings +from commitizen.exceptions import InvalidConfigurationError if TYPE_CHECKING: import sys + from collections.abc import Mapping # Self is Python 3.11+ but backported in typing-extensions if sys.version_info < (3, 11): @@ -52,6 +55,30 @@ def update(self, data: Settings) -> None: def _parse_setting(self, data: bytes | str) -> None: raise NotImplementedError() + def _validate_known_keys(self, raw_settings: Mapping[str, Any]) -> None: + """Detect unknown keys in the commitizen section of the config file. + + - When ``strict_config`` is ``True`` in ``raw_settings``, raise + :class:`InvalidConfigurationError`. + - Otherwise emit a warning so users notice typos without breaking back + compatibility. + """ + unknown_keys = sorted(k for k in raw_settings if k not in KNOWN_SETTINGS_KEYS) + if not unknown_keys: + return + + location = f" in '{self._path}'" if self._path is not None else "" + keys_str = ", ".join(repr(k) for k in unknown_keys) + plural = "keys" if len(unknown_keys) > 1 else "key" + message = ( + f"Unknown commitizen configuration {plural}{location}: {keys_str}. " + f"If this is intentional, move the value(s) under the 'extras' setting." + ) + + if bool(raw_settings.get("strict_config", False)): + raise InvalidConfigurationError(message) + out.warn(message) + def init_empty_config_content(self) -> None: """Create a config file with the empty config content. diff --git a/commitizen/config/json_config.py b/commitizen/config/json_config.py index 688a6b9fe..aec1112a5 100644 --- a/commitizen/config/json_config.py +++ b/commitizen/config/json_config.py @@ -65,6 +65,9 @@ def _parse_setting(self, data: bytes | str) -> None: raise InvalidConfigurationError(f"Failed to parse {self.path}: {e}") try: - self.settings.update(doc["commitizen"]) + section = doc["commitizen"] except KeyError: - pass + return + + self.settings.update(section) + self._validate_known_keys(section) diff --git a/commitizen/config/toml_config.py b/commitizen/config/toml_config.py index 28c05aaa5..0e9f2a4ca 100644 --- a/commitizen/config/toml_config.py +++ b/commitizen/config/toml_config.py @@ -65,6 +65,9 @@ def _parse_setting(self, data: bytes | str) -> None: raise InvalidConfigurationError(f"Failed to parse {self.path}: {e}") try: - self.settings.update(doc["tool"]["commitizen"]) # type: ignore[index,typeddict-item] # TODO: fix this + section = doc["tool"]["commitizen"] # type: ignore[index] except exceptions.NonExistentKey: - pass + return + + self.settings.update(section) # type: ignore[typeddict-item] # TODO: fix this + self._validate_known_keys(section) # type: ignore[arg-type] diff --git a/commitizen/config/yaml_config.py b/commitizen/config/yaml_config.py index 1e9610e17..1477bbba9 100644 --- a/commitizen/config/yaml_config.py +++ b/commitizen/config/yaml_config.py @@ -51,9 +51,12 @@ def _parse_setting(self, data: bytes | str) -> None: raise InvalidConfigurationError(f"Failed to parse {self.path}: {e}") try: - self.settings.update(doc["commitizen"]) + section = doc["commitizen"] except (KeyError, TypeError): - pass + return + + self.settings.update(section) + self._validate_known_keys(section) def set_key(self, key: str, value: object) -> Self: with self.path.open("rb") as yaml_file: diff --git a/commitizen/defaults.py b/commitizen/defaults.py index 4865ccc18..5fec12ead 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -65,6 +65,7 @@ class Settings(TypedDict, total=False): version_type: str | None version: str | None breaking_change_exclamation_in_title: bool + strict_config: bool CONFIG_FILES: tuple[str, ...] = ( @@ -115,8 +116,12 @@ class Settings(TypedDict, total=False): "extras": {}, "breaking_change_exclamation_in_title": False, "message_length_limit": 0, # 0 for no limit + "strict_config": False, } + +KNOWN_SETTINGS_KEYS: frozenset[str] = frozenset(Settings.__annotations__) + MAJOR = "MAJOR" MINOR = "MINOR" PATCH = "PATCH" diff --git a/docs/config/option.md b/docs/config/option.md index bfe976c4f..42288c436 100644 --- a/docs/config/option.md +++ b/docs/config/option.md @@ -44,6 +44,31 @@ Custom rules for committing and bumping. See [customization](../customization/config_file.md) for more details. +## `strict_config` + +When enabled, Commitizen raises an error if the configuration file contains +keys that are not recognized as valid commitizen settings (for example because +of a typo such as `update_changelog_on_bumb` instead of +`update_changelog_on_bump`). + +When disabled (the default), unknown keys only produce a warning so they can be +spotted without breaking existing setups. + +- Type: `bool` +- Default: `False` + +**Example** + +```toml title="pyproject.toml" +[tool.commitizen] +name = "cz_conventional_commits" +strict_config = true +``` + +If you intentionally need to keep additional plugin-specific data inside the +commitizen section, put it under the `extras` setting so it is not flagged as +unknown. + ## `use_shortcuts` Show keyboard shortcuts when selecting from a list. When enabled, each choice shows a shortcut key; press that key or use the arrow keys to select. diff --git a/tests/test_conf.py b/tests/test_conf.py index c004e96e1..3c8769fbc 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -112,6 +112,7 @@ "extras": {}, "breaking_change_exclamation_in_title": False, "message_length_limit": 0, + "strict_config": False, } _new_settings: dict[str, Any] = { @@ -152,6 +153,7 @@ "extras": {}, "breaking_change_exclamation_in_title": False, "message_length_limit": 0, + "strict_config": False, } @@ -497,3 +499,125 @@ def test_init_with_invalid_content(self, tmp_path, config_file): with pytest.raises(InvalidConfigurationError) as excinfo: YAMLConfig(data=existing_content, path=path) assert config_file in str(excinfo.value) + + +class TestUnknownConfigKeys: + """Validate handling of unknown keys in the commitizen section.""" + + @pytest.mark.parametrize( + ("config_file", "content_template"), + [ + ( + "pyproject.toml", + '[tool.commitizen]\nname = "cz_conventional_commits"\n{extra}\n', + ), + ( + ".cz.toml", + '[tool.commitizen]\nname = "cz_conventional_commits"\n{extra}\n', + ), + ( + ".cz.json", + '{{"commitizen": {{"name": "cz_conventional_commits"{extra}}}}}', + ), + ( + ".cz.yaml", + "commitizen:\n name: cz_conventional_commits\n{extra}\n", + ), + ], + ) + def test_warns_on_unknown_keys_by_default( + self, tmp_path, monkeypatch, capsys, config_file, content_template + ): + monkeypatch.chdir(tmp_path) + if config_file == ".cz.json": + extra = ', "update_changelog_on_bumb": true, "another_typo": 1' + elif config_file == ".cz.yaml": + extra = " update_changelog_on_bumb: true\n another_typo: 1" + else: + extra = "update_changelog_on_bumb = true\nanother_typo = 1" + (tmp_path / config_file).write_text(content_template.format(extra=extra)) + + cfg = config.read_cfg() + captured = capsys.readouterr() + + assert "Unknown commitizen configuration keys" in captured.err + assert "'another_typo'" in captured.err + assert "'update_changelog_on_bumb'" in captured.err + # The unknown keys are still loaded into settings (back-compat) but flagged. + assert cfg.settings["name"] == "cz_conventional_commits" + + @pytest.mark.parametrize( + ("config_file", "content"), + [ + ( + "pyproject.toml", + "[tool.commitizen]\n" + 'name = "cz_conventional_commits"\n' + "strict_config = true\n" + "update_changelog_on_bumb = true\n", + ), + ( + ".cz.json", + json.dumps( + { + "commitizen": { + "name": "cz_conventional_commits", + "strict_config": True, + "update_changelog_on_bumb": True, + } + } + ), + ), + ( + ".cz.yaml", + "commitizen:\n" + " name: cz_conventional_commits\n" + " strict_config: true\n" + " update_changelog_on_bumb: true\n", + ), + ], + ) + def test_raises_on_unknown_keys_when_strict( + self, tmp_path, monkeypatch, config_file, content + ): + monkeypatch.chdir(tmp_path) + (tmp_path / config_file).write_text(content) + + with pytest.raises(InvalidConfigurationError) as excinfo: + config.read_cfg() + assert "update_changelog_on_bumb" in str(excinfo.value) + + @pytest.mark.parametrize( + ("config_file", "content_template"), + [ + ( + "pyproject.toml", + '[tool.commitizen]\nname = "cz_conventional_commits"\n{extra}', + ), + ( + ".cz.json", + '{{"commitizen": {{"name": "cz_conventional_commits"{extra}}}}}', + ), + ( + ".cz.yaml", + "commitizen:\n name: cz_conventional_commits\n{extra}", + ), + ], + ) + def test_no_warning_for_known_keys( + self, tmp_path, monkeypatch, capsys, config_file, content_template + ): + monkeypatch.chdir(tmp_path) + if config_file == ".cz.json": + extra = ', "update_changelog_on_bump": true' + elif config_file == ".cz.yaml": + extra = " update_changelog_on_bump: true" + else: + extra = "update_changelog_on_bump = true" + + (tmp_path / config_file).write_text(content_template.format(extra=extra)) + + config.read_cfg() + captured = capsys.readouterr() + + assert "Unknown commitizen configuration" not in captured.err