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

1"""YAML configuration loading and the conventional preset.""" 

2 

3import copy 

4from pathlib import Path 

5from typing import Any 

6 

7from ruamel.yaml import YAML 

8 

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 

15 

16_LIST_SEVERITY_MAP = {0: "disabled", 1: "warning", 2: "error"} 

17_KNOWN_PRESETS = frozenset({"conventional"}) 

18 

19 

20class ConfigurationLoader: 

21 """Loads commitlint configuration from YAML or the conventional preset. 

22 

23 Resolution order when no explicit path is given: 

24 

25 1. ``.commitlintrc.yaml`` / ``.commitlintrc.yml`` 

26 2. ``commitlint.yaml`` / ``commitlint.yml`` 

27 3. The built-in ``CONVENTIONAL_CONFIG`` 

28 

29 A YAML file may declare ``extends: conventional`` to layer custom rules 

30 on top of the preset. 

31 """ 

32 

33 DEFAULT_CONFIG_FILES = [ 

34 ".commitlintrc.yaml", 

35 ".commitlintrc.yml", 

36 "commitlint.yaml", 

37 "commitlint.yml", 

38 ] 

39 

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 } 

107 

108 def load(self, config_path: Path | None = None) -> Configuration: 

109 """Load and parse a :class:`Configuration`. 

110 

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. 

115 

116 Returns: 

117 The fully-resolved :class:`Configuration`. 

118 

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) 

125 

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) 

129 

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) 

134 

135 return copy.deepcopy(self.CONVENTIONAL_CONFIG) 

136 

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 {} 

142 

143 if "extends" in config: 

144 extends = config["extends"] 

145 if isinstance(extends, str): 

146 extends = [extends] 

147 

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 ) 

154 

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 

159 

160 return config 

161 

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 ) 

168 

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) 

172 

173 extends = raw_config.get("extends", []) 

174 if isinstance(extends, str): 

175 extends = [extends] 

176 

177 return Configuration(rules=rules, extends=extends) 

178 

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 ) 

190 

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) 

211 

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) 

226 

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 

235 

236 

237class ConfigurationLoaderFactory: 

238 """Constructs :class:`ConfigurationLoader` instances.""" 

239 

240 @staticmethod 

241 def create() -> ConfigurationLoader: 

242 """Return a default :class:`ConfigurationLoader`.""" 

243 return ConfigurationLoader()