Coverage for src / python_commitlint / config / configuration.py: 89%
85 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-28 02:54 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-28 02:54 +0000
1"""YAML configuration loading and the conventional preset."""
3import copy
4from pathlib import Path
5from typing import Any
7from ruamel.yaml import YAML
9from python_commitlint.core.enums import (
10 RuleCondition,
11 Severity,
12)
13from python_commitlint.core.exceptions import ConfigurationError
14from python_commitlint.core.models import Configuration, RuleConfig
16_LIST_SEVERITY_MAP = {0: "disabled", 1: "warning", 2: "error"}
17_KNOWN_PRESETS = frozenset({"conventional"})
20class ConfigurationLoader:
21 """Loads commitlint configuration from YAML or the conventional preset.
23 Resolution order when no explicit path is given:
25 1. ``.commitlintrc.yaml`` / ``.commitlintrc.yml``
26 2. ``commitlint.yaml`` / ``commitlint.yml``
27 3. The built-in ``CONVENTIONAL_CONFIG``
29 A YAML file may declare ``extends: conventional`` to layer custom rules
30 on top of the preset.
31 """
33 DEFAULT_CONFIG_FILES = [
34 ".commitlintrc.yaml",
35 ".commitlintrc.yml",
36 "commitlint.yaml",
37 "commitlint.yml",
38 ]
40 CONVENTIONAL_CONFIG = {
41 "rules": {
42 "body-leading-blank": {
43 "severity": "warning",
44 "condition": "always",
45 },
46 "body-max-line-length": {
47 "severity": "error",
48 "condition": "always",
49 "value": 100,
50 },
51 "footer-leading-blank": {
52 "severity": "warning",
53 "condition": "always",
54 },
55 "footer-max-line-length": {
56 "severity": "error",
57 "condition": "always",
58 "value": 100,
59 },
60 "header-max-length": {
61 "severity": "error",
62 "condition": "always",
63 "value": 100,
64 },
65 "header-trim": {"severity": "error", "condition": "always"},
66 "subject-case": {
67 "severity": "error",
68 "condition": "never",
69 "value": [
70 "sentence-case",
71 "start-case",
72 "pascal-case",
73 "upper-case",
74 ],
75 },
76 "subject-empty": {"severity": "error", "condition": "never"},
77 "subject-full-stop": {
78 "severity": "error",
79 "condition": "never",
80 "value": ".",
81 },
82 "type-case": {
83 "severity": "error",
84 "condition": "always",
85 "value": "lower-case",
86 },
87 "type-empty": {"severity": "error", "condition": "never"},
88 "type-enum": {
89 "severity": "error",
90 "condition": "always",
91 "value": [
92 "build",
93 "chore",
94 "ci",
95 "docs",
96 "feat",
97 "fix",
98 "perf",
99 "refactor",
100 "revert",
101 "style",
102 "test",
103 ],
104 },
105 }
106 }
108 def load(self, config_path: Path | None = None) -> Configuration:
109 """Load and parse a :class:`Configuration`.
111 Args:
112 config_path: Optional explicit YAML path. When ``None``, the
113 loader searches the working directory for a default file
114 and falls back to the conventional preset.
116 Returns:
117 The fully-resolved :class:`Configuration`.
119 Raises:
120 ConfigurationError: If the YAML file is structurally invalid
121 (e.g. ``rules`` is not a mapping, severity is unknown).
122 """
123 raw_config = self._load_raw_config(config_path)
124 return self._parse_configuration(raw_config)
126 def _load_raw_config(self, config_path: Path | None) -> dict[str, Any]:
127 if config_path:
128 return self._read_config_file(config_path)
130 for config_file in self.DEFAULT_CONFIG_FILES:
131 candidate = Path(config_file)
132 if candidate.exists():
133 return self._read_config_file(candidate)
135 return copy.deepcopy(self.CONVENTIONAL_CONFIG)
137 def _read_config_file(self, path: Path) -> dict[str, Any]:
138 yaml = YAML()
139 yaml.preserve_quotes = True
140 with path.open("r") as f:
141 config = yaml.load(f) or {}
143 if "extends" in config:
144 extends = config["extends"]
145 if isinstance(extends, str):
146 extends = [extends]
148 unknown = [p for p in extends if p not in _KNOWN_PRESETS]
149 if unknown:
150 raise ConfigurationError(
151 f"unknown preset(s) in 'extends': {unknown!r} "
152 f"(supported: {sorted(_KNOWN_PRESETS)})"
153 )
155 if "conventional" in extends:
156 base_config = copy.deepcopy(self.CONVENTIONAL_CONFIG)
157 base_config["rules"].update(config.get("rules", {}))
158 return base_config
160 return config
162 def _parse_configuration(self, raw_config: dict[str, Any]) -> Configuration:
163 raw_rules = raw_config.get("rules", {})
164 if not isinstance(raw_rules, dict):
165 raise ConfigurationError(
166 f"'rules' must be a mapping, got {type(raw_rules).__name__}"
167 )
169 rules: dict[str, RuleConfig] = {}
170 for rule_name, rule_data in raw_rules.items():
171 rules[rule_name] = self._parse_rule_config(rule_name, rule_data)
173 extends = raw_config.get("extends", [])
174 if isinstance(extends, str):
175 extends = [extends]
177 return Configuration(rules=rules, extends=extends)
179 def _parse_rule_config(
180 self, rule_name: str, rule_data: dict[str, Any] | list[Any]
181 ) -> RuleConfig:
182 if isinstance(rule_data, list):
183 return self._parse_list_rule(rule_name, rule_data)
184 if isinstance(rule_data, dict):
185 return self._parse_dict_rule(rule_name, rule_data)
186 raise ConfigurationError(
187 f"{rule_name}: rule must be a mapping or list, "
188 f"got {type(rule_data).__name__}"
189 )
191 def _parse_list_rule(
192 self, rule_name: str, rule_data: list[Any]
193 ) -> RuleConfig:
194 if not rule_data:
195 raise ConfigurationError(
196 f"{rule_name}: rule list must not be empty"
197 )
198 severity_level = rule_data[0]
199 severity_str = _LIST_SEVERITY_MAP.get(severity_level)
200 if severity_str is None:
201 raise ConfigurationError(
202 f"{rule_name}: unknown severity level {severity_level!r} "
203 f"(expected 0, 1, or 2)"
204 )
205 severity = Severity(severity_str)
206 condition = self._parse_condition(
207 rule_name, rule_data[1] if len(rule_data) > 1 else "always"
208 )
209 value = rule_data[2] if len(rule_data) > 2 else None
210 return RuleConfig(severity=severity, condition=condition, value=value)
212 def _parse_dict_rule(
213 self, rule_name: str, rule_data: dict[str, Any]
214 ) -> RuleConfig:
215 try:
216 severity = Severity(rule_data.get("severity", "error"))
217 except ValueError as e:
218 raise ConfigurationError(
219 f"{rule_name}: invalid severity {rule_data.get('severity')!r}"
220 ) from e
221 condition = self._parse_condition(
222 rule_name, rule_data.get("condition", "always")
223 )
224 value = rule_data.get("value")
225 return RuleConfig(severity=severity, condition=condition, value=value)
227 def _parse_condition(self, rule_name: str, value: Any) -> RuleCondition:
228 try:
229 return RuleCondition(value)
230 except ValueError as e:
231 raise ConfigurationError(
232 f"{rule_name}: invalid condition {value!r} "
233 f"(expected 'always' or 'never')"
234 ) from e
237class ConfigurationLoaderFactory:
238 """Constructs :class:`ConfigurationLoader` instances."""
240 @staticmethod
241 def create() -> ConfigurationLoader:
242 """Return a default :class:`ConfigurationLoader`."""
243 return ConfigurationLoader()