diff --git a/commitizen/config/base_config.py b/commitizen/config/base_config.py index f100cf9953..fc38e34c2c 100644 --- a/commitizen/config/base_config.py +++ b/commitizen/config/base_config.py @@ -4,9 +4,11 @@ from typing import TYPE_CHECKING from commitizen.defaults import DEFAULT_SETTINGS, Settings +from commitizen.exceptions import InvalidConfigurationError if TYPE_CHECKING: import sys + from collections.abc import Iterable # Self is Python 3.11+ but backported in typing-extensions if sys.version_info < (3, 11): @@ -15,6 +17,15 @@ from typing import Self +# Top-level keys that may appear in the commitizen section of a +# configuration file. Derived from the Settings TypedDict plus +# ``annotated_tag_message``, which is read from settings but predates the +# TypedDict (see commitizen/commands/bump.py). +KNOWN_SETTINGS: frozenset[str] = frozenset( + set(Settings.__required_keys__) | set(Settings.__optional_keys__) +) | frozenset({"annotated_tag_message"}) + + class BaseConfig: def __init__(self) -> None: self._settings: Settings = DEFAULT_SETTINGS.copy() @@ -49,6 +60,26 @@ def set_key(self, key: str, value: object) -> Self: def update(self, data: Settings) -> None: self._settings.update(data) + def _check_unknown_keys(self, keys: Iterable[str]) -> None: + """Raise when the configuration contains keys that are not known settings. + + Only enforced when the ``strict_config`` setting is enabled. Unknown + top-level keys usually indicate a typo in the configuration file + (e.g. ``bump_mesage``); silently ignoring them makes such mistakes + hard to notice. Keys nested under ``customize`` and ``extras`` are + plugin-owned and deliberately not checked. + """ + if not self._settings.get("strict_config"): + return + + unknown_keys = sorted(key for key in keys if key not in KNOWN_SETTINGS) + if unknown_keys: + raise InvalidConfigurationError( + f"Unknown configuration key(s) in {self.path}: " + f"{', '.join(unknown_keys)}. " + "Check for typos in your configuration file." + ) + def _parse_setting(self, data: bytes | str) -> None: raise NotImplementedError() diff --git a/commitizen/config/json_config.py b/commitizen/config/json_config.py index 688a6b9fec..8ee4ace10d 100644 --- a/commitizen/config/json_config.py +++ b/commitizen/config/json_config.py @@ -65,6 +65,8 @@ def _parse_setting(self, data: bytes | str) -> None: raise InvalidConfigurationError(f"Failed to parse {self.path}: {e}") try: - self.settings.update(doc["commitizen"]) + commitizen_section = doc["commitizen"] + self.settings.update(commitizen_section) + self._check_unknown_keys(commitizen_section.keys()) except KeyError: pass diff --git a/commitizen/config/toml_config.py b/commitizen/config/toml_config.py index 28c05aaa52..36730e8670 100644 --- a/commitizen/config/toml_config.py +++ b/commitizen/config/toml_config.py @@ -10,7 +10,9 @@ if TYPE_CHECKING: import sys + from collections.abc import Mapping from pathlib import Path + from typing import Any # Self is Python 3.11+ but backported in typing-extensions if sys.version_info < (3, 11): @@ -65,6 +67,8 @@ 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 + commitizen_section: Mapping[str, Any] = doc["tool"]["commitizen"] # type: ignore[index, assignment] + self.settings.update(commitizen_section) # type: ignore[typeddict-item] # TODO: fix this + self._check_unknown_keys(commitizen_section.keys()) except exceptions.NonExistentKey: pass diff --git a/commitizen/config/yaml_config.py b/commitizen/config/yaml_config.py index 1e9610e17a..6c25e7cf0b 100644 --- a/commitizen/config/yaml_config.py +++ b/commitizen/config/yaml_config.py @@ -51,7 +51,9 @@ def _parse_setting(self, data: bytes | str) -> None: raise InvalidConfigurationError(f"Failed to parse {self.path}: {e}") try: - self.settings.update(doc["commitizen"]) + commitizen_section = doc["commitizen"] + self.settings.update(commitizen_section) + self._check_unknown_keys(commitizen_section.keys()) except (KeyError, TypeError): pass diff --git a/commitizen/defaults.py b/commitizen/defaults.py index 93bb835a38..3e2ccdee24 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -55,6 +55,7 @@ class Settings(TypedDict, total=False): pre_bump_hooks: list[str] | None prerelease_offset: int retry_after_failure: bool + strict_config: bool style: list[tuple[str, str]] tag_format: str template: str | None @@ -90,6 +91,7 @@ class Settings(TypedDict, total=False): "ignored_tag_formats": [], "bump_message": None, # bumped v$current_version to $new_version "retry_after_failure": False, + "strict_config": False, "allow_abort": False, "allowed_prefixes": [ "Merge", diff --git a/docs/config/configuration_file.md b/docs/config/configuration_file.md index 172cbce1a3..99377ab55a 100644 --- a/docs/config/configuration_file.md +++ b/docs/config/configuration_file.md @@ -237,6 +237,7 @@ Key configuration categories include: - **Changelog**: `changelog_file`, `changelog_format`, `changelog_incremental`, `update_changelog_on_bump` - **Bumping**: `bump_message`, `major_version_zero`, `prerelease_offset`, `pre_bump_hooks`, `post_bump_hooks` - **Commit Validation**: `allowed_prefixes`, `message_length_limit`, `allow_abort`, `retry_after_failure` +- **Configuration Validation**: `strict_config` - reject unknown keys in the configuration file - **Customization**: `customize`, `style`, `use_shortcuts`, `template`, `extras` ## Customization diff --git a/docs/config/option.md b/docs/config/option.md index bfe976c4f8..682b5d7317 100644 --- a/docs/config/option.md +++ b/docs/config/option.md @@ -33,6 +33,25 @@ Style for the prompts. It will merge this value with default style. See [Styling your prompts with your favorite colors](https://github.com/tmbo/questionary#additional-features) for more details. +## `strict_config` + +Reject unknown keys in the `[tool.commitizen]` (or `commitizen`) section of the configuration file. + +- Type: `bool` +- Default: `false` + +When enabled, any unknown top-level key makes Commitizen fail with an `InvalidConfigurationError` listing the offending keys. This is useful to catch typos such as `bump_mesage` instead of silently ignoring them. + +Keys nested under `customize` and `extras` are plugin-owned and are not checked. + +**Example** + +```toml title="pyproject.toml" +[tool.commitizen] +name = "cz_conventional_commits" +strict_config = true +``` + ## `customize` Custom rules for committing and bumping. diff --git a/tests/test_conf.py b/tests/test_conf.py index 15be0630aa..267fc46f57 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -109,6 +109,7 @@ "prerelease_offset": 0, "encoding": "utf-8", "always_signoff": False, + "strict_config": False, "template": None, "extras": {}, "breaking_change_exclamation_in_title": False, @@ -150,6 +151,7 @@ "prerelease_offset": 0, "encoding": "utf-8", "always_signoff": False, + "strict_config": False, "template": None, "extras": {}, "breaking_change_exclamation_in_title": False, @@ -497,3 +499,61 @@ def test_init_with_invalid_content(self, tmp_path, config_file): with pytest.raises(InvalidConfigurationError, match=re.escape(config_file)): YAMLConfig(data=existing_content, path=path) + + +class TestStrictConfig: + @pytest.mark.parametrize( + ("config_content", "config_path"), + [ + pytest.param( + '[tool.commitizen]\nname = "cz_conventional_commits"\n' + 'strict_config = true\nbump_mesage = "typo"\n', + "pyproject.toml", + id="toml", + ), + pytest.param( + '{"commitizen": {"name": "cz_conventional_commits", ' + '"strict_config": true, "bump_mesage": "typo"}}', + ".cz.json", + id="json", + ), + pytest.param( + "commitizen:\n name: cz_conventional_commits\n" + " strict_config: true\n bump_mesage: typo\n", + ".cz.yaml", + id="yaml", + ), + ], + ) + def test_strict_config_rejects_unknown_keys( + self, tmp_path, config_content, config_path + ): + path = tmp_path / config_path + path.write_text(config_content, encoding="utf-8") + + with pytest.raises(InvalidConfigurationError, match="bump_mesage"): + config.create_config(data=config_content, path=path) + + def test_unknown_keys_allowed_when_strict_config_disabled(self, tmp_path): + path = tmp_path / "pyproject.toml" + path.write_text( + '[tool.commitizen]\nname = "cz_conventional_commits"\nunknown_key = 1\n', + encoding="utf-8", + ) + + conf = config.create_config(data=path.read_text(), path=path) + + assert conf.settings["name"] == "cz_conventional_commits" + + def test_known_keys_accepted_when_strict_config_enabled(self, tmp_path): + path = tmp_path / "pyproject.toml" + path.write_text( + '[tool.commitizen]\nname = "cz_conventional_commits"\n' + 'strict_config = true\nannotated_tag_message = "bump: $current_version"\n', + encoding="utf-8", + ) + + conf = config.create_config(data=path.read_text(), path=path) + + assert conf.settings["strict_config"] is True + assert conf.settings["annotated_tag_message"] == "bump: $current_version"