Skip to content

Commit 96edb12

Browse files
feat(config): warn on unknown configuration keys
Add a `strict_config` boolean setting (default `False`) that controls how unknown keys inside the commitizen configuration section are handled. * By default, unknown keys produce a warning so typos like `update_changelog_on_bumb` are surfaced without breaking existing setups. * When `strict_config = true` is set, the same condition raises `InvalidConfigurationError` (exit code `INVALID_CONFIGURATION`). Validation is implemented in `BaseConfig._validate_known_keys` and called from each concrete parser (`TomlConfig`, `JsonConfig`, `YAMLConfig`) right after the section is loaded. Closes #300 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4d99415 commit 96edb12

7 files changed

Lines changed: 198 additions & 8 deletions

File tree

commitizen/config/base_config.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
from __future__ import annotations
22

33
from pathlib import Path
4-
from typing import TYPE_CHECKING
4+
from typing import TYPE_CHECKING, Any
55

6-
from commitizen.defaults import DEFAULT_SETTINGS, Settings
6+
from commitizen import out
7+
from commitizen.defaults import DEFAULT_SETTINGS, KNOWN_SETTINGS_KEYS, Settings
8+
from commitizen.exceptions import InvalidConfigurationError
79

810
if TYPE_CHECKING:
911
import sys
12+
from collections.abc import Mapping
1013

1114
# Self is Python 3.11+ but backported in typing-extensions
1215
if sys.version_info < (3, 11):
@@ -52,6 +55,30 @@ def update(self, data: Settings) -> None:
5255
def _parse_setting(self, data: bytes | str) -> None:
5356
raise NotImplementedError()
5457

58+
def _validate_known_keys(self, raw_settings: Mapping[str, Any]) -> None:
59+
"""Detect unknown keys in the commitizen section of the config file.
60+
61+
- When ``strict_config`` is ``True`` in ``raw_settings``, raise
62+
:class:`InvalidConfigurationError`.
63+
- Otherwise emit a warning so users notice typos without breaking back
64+
compatibility.
65+
"""
66+
unknown_keys = sorted(k for k in raw_settings if k not in KNOWN_SETTINGS_KEYS)
67+
if not unknown_keys:
68+
return
69+
70+
location = f" in '{self._path}'" if self._path is not None else ""
71+
keys_str = ", ".join(repr(k) for k in unknown_keys)
72+
plural = "keys" if len(unknown_keys) > 1 else "key"
73+
message = (
74+
f"Unknown commitizen configuration {plural}{location}: {keys_str}. "
75+
f"If this is intentional, move the value(s) under the 'extras' setting."
76+
)
77+
78+
if bool(raw_settings.get("strict_config", False)):
79+
raise InvalidConfigurationError(message)
80+
out.warn(message)
81+
5582
def init_empty_config_content(self) -> None:
5683
"""Create a config file with the empty config content.
5784

commitizen/config/json_config.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ def _parse_setting(self, data: bytes | str) -> None:
6565
raise InvalidConfigurationError(f"Failed to parse {self.path}: {e}")
6666

6767
try:
68-
self.settings.update(doc["commitizen"])
68+
section = doc["commitizen"]
6969
except KeyError:
70-
pass
70+
return
71+
72+
self.settings.update(section)
73+
self._validate_known_keys(section)

commitizen/config/toml_config.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ def _parse_setting(self, data: bytes | str) -> None:
6565
raise InvalidConfigurationError(f"Failed to parse {self.path}: {e}")
6666

6767
try:
68-
self.settings.update(doc["tool"]["commitizen"]) # type: ignore[index,typeddict-item] # TODO: fix this
68+
section = doc["tool"]["commitizen"] # type: ignore[index]
6969
except exceptions.NonExistentKey:
70-
pass
70+
return
71+
72+
self.settings.update(section) # type: ignore[typeddict-item] # TODO: fix this
73+
self._validate_known_keys(section) # type: ignore[arg-type]

commitizen/config/yaml_config.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,12 @@ def _parse_setting(self, data: bytes | str) -> None:
5151
raise InvalidConfigurationError(f"Failed to parse {self.path}: {e}")
5252

5353
try:
54-
self.settings.update(doc["commitizen"])
54+
section = doc["commitizen"]
5555
except (KeyError, TypeError):
56-
pass
56+
return
57+
58+
self.settings.update(section)
59+
self._validate_known_keys(section)
5760

5861
def set_key(self, key: str, value: object) -> Self:
5962
with self.path.open("rb") as yaml_file:

commitizen/defaults.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ class Settings(TypedDict, total=False):
6565
version_type: str | None
6666
version: str | None
6767
breaking_change_exclamation_in_title: bool
68+
strict_config: bool
6869

6970

7071
CONFIG_FILES: tuple[str, ...] = (
@@ -115,8 +116,12 @@ class Settings(TypedDict, total=False):
115116
"extras": {},
116117
"breaking_change_exclamation_in_title": False,
117118
"message_length_limit": 0, # 0 for no limit
119+
"strict_config": False,
118120
}
119121

122+
123+
KNOWN_SETTINGS_KEYS: frozenset[str] = frozenset(Settings.__annotations__)
124+
120125
MAJOR = "MAJOR"
121126
MINOR = "MINOR"
122127
PATCH = "PATCH"

docs/config/option.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,31 @@ Custom rules for committing and bumping.
4444

4545
See [customization](../customization/config_file.md) for more details.
4646

47+
## `strict_config`
48+
49+
When enabled, Commitizen raises an error if the configuration file contains
50+
keys that are not recognized as valid commitizen settings (for example because
51+
of a typo such as `update_changelog_on_bumb` instead of
52+
`update_changelog_on_bump`).
53+
54+
When disabled (the default), unknown keys only produce a warning so they can be
55+
spotted without breaking existing setups.
56+
57+
- Type: `bool`
58+
- Default: `False`
59+
60+
**Example**
61+
62+
```toml title="pyproject.toml"
63+
[tool.commitizen]
64+
name = "cz_conventional_commits"
65+
strict_config = true
66+
```
67+
68+
If you intentionally need to keep additional plugin-specific data inside the
69+
commitizen section, put it under the `extras` setting so it is not flagged as
70+
unknown.
71+
4772
## `use_shortcuts`
4873

4974
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.

tests/test_conf.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@
112112
"extras": {},
113113
"breaking_change_exclamation_in_title": False,
114114
"message_length_limit": 0,
115+
"strict_config": False,
115116
}
116117

117118
_new_settings: dict[str, Any] = {
@@ -152,6 +153,7 @@
152153
"extras": {},
153154
"breaking_change_exclamation_in_title": False,
154155
"message_length_limit": 0,
156+
"strict_config": False,
155157
}
156158

157159

@@ -497,3 +499,125 @@ def test_init_with_invalid_content(self, tmp_path, config_file):
497499
with pytest.raises(InvalidConfigurationError) as excinfo:
498500
YAMLConfig(data=existing_content, path=path)
499501
assert config_file in str(excinfo.value)
502+
503+
504+
class TestUnknownConfigKeys:
505+
"""Validate handling of unknown keys in the commitizen section."""
506+
507+
@pytest.mark.parametrize(
508+
("config_file", "content_template"),
509+
[
510+
(
511+
"pyproject.toml",
512+
'[tool.commitizen]\nname = "cz_conventional_commits"\n{extra}\n',
513+
),
514+
(
515+
".cz.toml",
516+
'[tool.commitizen]\nname = "cz_conventional_commits"\n{extra}\n',
517+
),
518+
(
519+
".cz.json",
520+
'{{"commitizen": {{"name": "cz_conventional_commits"{extra}}}}}',
521+
),
522+
(
523+
".cz.yaml",
524+
"commitizen:\n name: cz_conventional_commits\n{extra}\n",
525+
),
526+
],
527+
)
528+
def test_warns_on_unknown_keys_by_default(
529+
self, tmp_path, monkeypatch, capsys, config_file, content_template
530+
):
531+
monkeypatch.chdir(tmp_path)
532+
if config_file == ".cz.json":
533+
extra = ', "update_changelog_on_bumb": true, "another_typo": 1'
534+
elif config_file == ".cz.yaml":
535+
extra = " update_changelog_on_bumb: true\n another_typo: 1"
536+
else:
537+
extra = "update_changelog_on_bumb = true\nanother_typo = 1"
538+
(tmp_path / config_file).write_text(content_template.format(extra=extra))
539+
540+
cfg = config.read_cfg()
541+
captured = capsys.readouterr()
542+
543+
assert "Unknown commitizen configuration keys" in captured.err
544+
assert "'another_typo'" in captured.err
545+
assert "'update_changelog_on_bumb'" in captured.err
546+
# The unknown keys are still loaded into settings (back-compat) but flagged.
547+
assert cfg.settings["name"] == "cz_conventional_commits"
548+
549+
@pytest.mark.parametrize(
550+
("config_file", "content"),
551+
[
552+
(
553+
"pyproject.toml",
554+
"[tool.commitizen]\n"
555+
'name = "cz_conventional_commits"\n'
556+
"strict_config = true\n"
557+
"update_changelog_on_bumb = true\n",
558+
),
559+
(
560+
".cz.json",
561+
json.dumps(
562+
{
563+
"commitizen": {
564+
"name": "cz_conventional_commits",
565+
"strict_config": True,
566+
"update_changelog_on_bumb": True,
567+
}
568+
}
569+
),
570+
),
571+
(
572+
".cz.yaml",
573+
"commitizen:\n"
574+
" name: cz_conventional_commits\n"
575+
" strict_config: true\n"
576+
" update_changelog_on_bumb: true\n",
577+
),
578+
],
579+
)
580+
def test_raises_on_unknown_keys_when_strict(
581+
self, tmp_path, monkeypatch, config_file, content
582+
):
583+
monkeypatch.chdir(tmp_path)
584+
(tmp_path / config_file).write_text(content)
585+
586+
with pytest.raises(InvalidConfigurationError) as excinfo:
587+
config.read_cfg()
588+
assert "update_changelog_on_bumb" in str(excinfo.value)
589+
590+
@pytest.mark.parametrize(
591+
("config_file", "content_template"),
592+
[
593+
(
594+
"pyproject.toml",
595+
'[tool.commitizen]\nname = "cz_conventional_commits"\n{extra}',
596+
),
597+
(
598+
".cz.json",
599+
'{{"commitizen": {{"name": "cz_conventional_commits"{extra}}}}}',
600+
),
601+
(
602+
".cz.yaml",
603+
"commitizen:\n name: cz_conventional_commits\n{extra}",
604+
),
605+
],
606+
)
607+
def test_no_warning_for_known_keys(
608+
self, tmp_path, monkeypatch, capsys, config_file, content_template
609+
):
610+
monkeypatch.chdir(tmp_path)
611+
if config_file == ".cz.json":
612+
extra = ', "update_changelog_on_bump": true'
613+
elif config_file == ".cz.yaml":
614+
extra = " update_changelog_on_bump: true"
615+
else:
616+
extra = "update_changelog_on_bump = true"
617+
618+
(tmp_path / config_file).write_text(content_template.format(extra=extra))
619+
620+
config.read_cfg()
621+
captured = capsys.readouterr()
622+
623+
assert "Unknown commitizen configuration" not in captured.err

0 commit comments

Comments
 (0)