Coverage for src / python_commitlint / rules / scope_rules.py: 86%
103 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"""Rules that validate the optional scope inside ``type(scope): subject``."""
3from typing import Any
5from python_commitlint.core.enums import CaseType, RuleCondition
6from python_commitlint.core.exceptions import ConfigurationError
7from python_commitlint.core.models import (
8 CaseValidation,
9 CommitMessage,
10 RuleConfig,
11 ScopeEnumValidation,
12 ValidationError,
13)
14from python_commitlint.rules.base import BaseRule, config_value_or
15from python_commitlint.rules.case_validators import CaseValidator
17_DEFAULT_SCOPE_DELIMITERS = ("/", "\\", ",")
20def _split_scope(scope: str, delimiters: list[str]) -> list[str]:
21 parts = [scope]
22 for delimiter in delimiters:
23 new_parts: list[str] = []
24 for part in parts:
25 new_parts.extend(part.split(delimiter))
26 parts = new_parts
27 return [p for p in parts if p]
30def _build_case_validation(
31 rule_name: str, value: dict[str, Any]
32) -> CaseValidation:
33 try:
34 return CaseValidation(**value)
35 except TypeError as e:
36 raise ConfigurationError(
37 f"{rule_name}: invalid case validation config: {e}"
38 ) from e
41def _build_scope_enum_validation(
42 rule_name: str, value: dict[str, Any]
43) -> ScopeEnumValidation:
44 try:
45 return ScopeEnumValidation(**value)
46 except TypeError as e:
47 raise ConfigurationError(
48 f"{rule_name}: invalid scope enum config: {e}"
49 ) from e
52class ScopeEmptyRule(BaseRule):
53 """Require or forbid an empty scope. Rule name: ``scope-empty``."""
55 @property
56 def name(self) -> str:
57 return "scope-empty"
59 def validate(
60 self, commit: CommitMessage, config: RuleConfig
61 ) -> ValidationError | None:
62 is_empty = not commit.scope
63 should_be_empty = config.condition == RuleCondition.ALWAYS
65 if is_empty != should_be_empty:
66 msg = (
67 "scope may not be empty"
68 if not should_be_empty
69 else "scope must be empty"
70 )
71 return self._create_error(config, msg)
72 return None
75class ScopeCaseRule(BaseRule):
76 """Enforce a case style on each scope part. Rule name: ``scope-case``."""
78 @property
79 def name(self) -> str:
80 return "scope-case"
82 def validate(
83 self, commit: CommitMessage, config: RuleConfig
84 ) -> ValidationError | None:
85 if not commit.scope or config.value is None:
86 return None
88 if isinstance(config.value, dict):
89 validation = _build_case_validation(self.name, config.value)
90 case_types = validation.cases
91 delimiters = validation.delimiters
92 else:
93 case_types = (
94 [CaseType(config.value)]
95 if isinstance(config.value, str)
96 else [CaseType(v) for v in config.value]
97 )
98 delimiters = list(_DEFAULT_SCOPE_DELIMITERS)
100 scope_parts = _split_scope(commit.scope, delimiters)
101 matches_any = all(
102 any(
103 CaseValidator.validate(part, case_type)
104 for case_type in case_types
105 )
106 for part in scope_parts
107 )
109 should_match = config.condition == RuleCondition.ALWAYS
111 if matches_any != should_match:
112 return self._create_error(
113 config, f"scope must match case {config.value}"
114 )
115 return None
118class ScopeEnumRule(BaseRule):
119 """Restrict scope parts to (or away from) an allowed list. Rule name: ``scope-enum``."""
121 @property
122 def name(self) -> str:
123 return "scope-enum"
125 def validate(
126 self, commit: CommitMessage, config: RuleConfig
127 ) -> ValidationError | None:
128 if not commit.scope:
129 return None
131 if isinstance(config.value, dict):
132 validation = _build_scope_enum_validation(self.name, config.value)
133 allowed_scopes = validation.scopes
134 delimiters = validation.delimiters
135 else:
136 if config.value is None:
137 raise ConfigurationError(
138 f"{self.name}: requires a 'value' (list of allowed scopes)"
139 )
140 allowed_scopes = config.value
141 delimiters = list(_DEFAULT_SCOPE_DELIMITERS)
143 scope_parts = _split_scope(commit.scope, delimiters)
144 all_in_enum = all(part in allowed_scopes for part in scope_parts)
145 should_be_in_enum = config.condition == RuleCondition.ALWAYS
147 if all_in_enum != should_be_in_enum:
148 msg = (
149 f"scope must be one of {allowed_scopes}"
150 if should_be_in_enum
151 else f"scope must not be one of {allowed_scopes}"
152 )
153 return self._create_error(config, msg)
154 return None
157class ScopeMinLengthRule(BaseRule):
158 """Enforce a minimum scope length. Rule name: ``scope-min-length``."""
160 @property
161 def name(self) -> str:
162 return "scope-min-length"
164 def validate(
165 self, commit: CommitMessage, config: RuleConfig
166 ) -> ValidationError | None:
167 if not commit.scope:
168 return None
170 min_length = config_value_or(config, 0)
171 is_valid = len(commit.scope) >= min_length
172 should_be_valid = config.condition == RuleCondition.ALWAYS
174 if is_valid != should_be_valid:
175 return self._create_error(
176 config, f"scope must be at least {min_length} characters"
177 )
178 return None
181class ScopeMaxLengthRule(BaseRule):
182 """Enforce a maximum scope length. Rule name: ``scope-max-length``."""
184 @property
185 def name(self) -> str:
186 return "scope-max-length"
188 def validate(
189 self, commit: CommitMessage, config: RuleConfig
190 ) -> ValidationError | None:
191 if not commit.scope:
192 return None
194 max_length = config_value_or(config, float("inf"))
195 is_valid = len(commit.scope) <= max_length
196 should_be_valid = config.condition == RuleCondition.ALWAYS
198 if is_valid != should_be_valid:
199 return self._create_error(
200 config, f"scope must be at most {max_length} characters"
201 )
202 return None