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

1"""Rules that validate the optional scope inside ``type(scope): subject``.""" 

2 

3from typing import Any 

4 

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 

16 

17_DEFAULT_SCOPE_DELIMITERS = ("/", "\\", ",") 

18 

19 

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] 

28 

29 

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 

39 

40 

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 

50 

51 

52class ScopeEmptyRule(BaseRule): 

53 """Require or forbid an empty scope. Rule name: ``scope-empty``.""" 

54 

55 @property 

56 def name(self) -> str: 

57 return "scope-empty" 

58 

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 

64 

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 

73 

74 

75class ScopeCaseRule(BaseRule): 

76 """Enforce a case style on each scope part. Rule name: ``scope-case``.""" 

77 

78 @property 

79 def name(self) -> str: 

80 return "scope-case" 

81 

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 

87 

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) 

99 

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 ) 

108 

109 should_match = config.condition == RuleCondition.ALWAYS 

110 

111 if matches_any != should_match: 

112 return self._create_error( 

113 config, f"scope must match case {config.value}" 

114 ) 

115 return None 

116 

117 

118class ScopeEnumRule(BaseRule): 

119 """Restrict scope parts to (or away from) an allowed list. Rule name: ``scope-enum``.""" 

120 

121 @property 

122 def name(self) -> str: 

123 return "scope-enum" 

124 

125 def validate( 

126 self, commit: CommitMessage, config: RuleConfig 

127 ) -> ValidationError | None: 

128 if not commit.scope: 

129 return None 

130 

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) 

142 

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 

146 

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 

155 

156 

157class ScopeMinLengthRule(BaseRule): 

158 """Enforce a minimum scope length. Rule name: ``scope-min-length``.""" 

159 

160 @property 

161 def name(self) -> str: 

162 return "scope-min-length" 

163 

164 def validate( 

165 self, commit: CommitMessage, config: RuleConfig 

166 ) -> ValidationError | None: 

167 if not commit.scope: 

168 return None 

169 

170 min_length = config_value_or(config, 0) 

171 is_valid = len(commit.scope) >= min_length 

172 should_be_valid = config.condition == RuleCondition.ALWAYS 

173 

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 

179 

180 

181class ScopeMaxLengthRule(BaseRule): 

182 """Enforce a maximum scope length. Rule name: ``scope-max-length``.""" 

183 

184 @property 

185 def name(self) -> str: 

186 return "scope-max-length" 

187 

188 def validate( 

189 self, commit: CommitMessage, config: RuleConfig 

190 ) -> ValidationError | None: 

191 if not commit.scope: 

192 return None 

193 

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 

197 

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