Coverage for src / python_commitlint / rules / body_rules.py: 99%

102 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-04-28 02:54 +0000

1"""Rules that validate the body — the paragraphs between header and footer.""" 

2 

3import re 

4 

5from python_commitlint.core.enums import CaseType, RuleCondition 

6from python_commitlint.core.models import ( 

7 CommitMessage, 

8 RuleConfig, 

9 ValidationError, 

10) 

11from python_commitlint.rules.base import BaseRule, config_value_or 

12from python_commitlint.rules.case_validators import CaseValidator 

13 

14# A line that consists entirely of a URL (with optional surrounding whitespace) 

15# is exempt from body-max-line-length, matching upstream commitlint. 

16_URL_ONLY_LINE = re.compile(r"^\s*https?://\S+\s*$") 

17 

18 

19class BodyEmptyRule(BaseRule): 

20 """Require or forbid an empty body. Rule name: ``body-empty``.""" 

21 

22 @property 

23 def name(self) -> str: 

24 return "body-empty" 

25 

26 def validate( 

27 self, commit: CommitMessage, config: RuleConfig 

28 ) -> ValidationError | None: 

29 is_empty = not commit.body 

30 should_be_empty = config.condition == RuleCondition.ALWAYS 

31 

32 if is_empty != should_be_empty: 

33 msg = ( 

34 "body may not be empty" 

35 if not should_be_empty 

36 else "body must be empty" 

37 ) 

38 return self._create_error(config, msg) 

39 return None 

40 

41 

42class BodyLeadingBlankRule(BaseRule): 

43 """Require a blank line between header and body. Rule name: ``body-leading-blank``.""" 

44 

45 @property 

46 def name(self) -> str: 

47 return "body-leading-blank" 

48 

49 def validate( 

50 self, commit: CommitMessage, config: RuleConfig 

51 ) -> ValidationError | None: 

52 if not commit.body: 

53 return None 

54 

55 lines = commit.raw.split("\n") 

56 if len(lines) < 2: 

57 return None 

58 

59 has_blank = len(lines) > 1 and not lines[1].strip() 

60 should_have_blank = config.condition == RuleCondition.ALWAYS 

61 

62 if has_blank != should_have_blank: 

63 msg = ( 

64 "body must have leading blank line" 

65 if should_have_blank 

66 else "body must not have leading blank line" 

67 ) 

68 return self._create_error(config, msg) 

69 return None 

70 

71 

72class BodyMaxLengthRule(BaseRule): 

73 """Enforce a maximum total body length. Rule name: ``body-max-length``.""" 

74 

75 @property 

76 def name(self) -> str: 

77 return "body-max-length" 

78 

79 def validate( 

80 self, commit: CommitMessage, config: RuleConfig 

81 ) -> ValidationError | None: 

82 if not commit.body: 

83 return None 

84 

85 max_length = config_value_or(config, float("inf")) 

86 is_valid = len(commit.body) <= max_length 

87 should_be_valid = config.condition == RuleCondition.ALWAYS 

88 

89 if is_valid != should_be_valid: 

90 return self._create_error( 

91 config, f"body must be at most {max_length} characters" 

92 ) 

93 return None 

94 

95 

96class BodyMaxLineLengthRule(BaseRule): 

97 """Enforce a per-line body length limit. Rule name: ``body-max-line-length``. 

98 

99 Lines that consist entirely of a URL are exempt to match upstream commitlint. 

100 """ 

101 

102 @property 

103 def name(self) -> str: 

104 return "body-max-line-length" 

105 

106 def validate( 

107 self, commit: CommitMessage, config: RuleConfig 

108 ) -> ValidationError | None: 

109 if not commit.body: 

110 return None 

111 

112 # Only the ALWAYS polarity is meaningful for a max-line-length 

113 # rule — "lines must NEVER be at most N characters" is nonsensical, 

114 # so under NEVER this rule is a no-op. 

115 if config.condition != RuleCondition.ALWAYS: 

116 return None 

117 

118 max_length = config_value_or(config, float("inf")) 

119 for line in commit.body.split("\n"): 

120 if _URL_ONLY_LINE.match(line): 

121 continue 

122 if len(line) > max_length: 

123 return self._create_error( 

124 config, 

125 f"body lines must be at most {max_length} characters", 

126 ) 

127 return None 

128 

129 

130class BodyMinLengthRule(BaseRule): 

131 """Enforce a minimum total body length. Rule name: ``body-min-length``.""" 

132 

133 @property 

134 def name(self) -> str: 

135 return "body-min-length" 

136 

137 def validate( 

138 self, commit: CommitMessage, config: RuleConfig 

139 ) -> ValidationError | None: 

140 if not commit.body: 

141 return None 

142 

143 min_length = config_value_or(config, 0) 

144 is_valid = len(commit.body) >= min_length 

145 should_be_valid = config.condition == RuleCondition.ALWAYS 

146 

147 if is_valid != should_be_valid: 

148 return self._create_error( 

149 config, f"body must be at least {min_length} characters" 

150 ) 

151 return None 

152 

153 

154class BodyFullStopRule(BaseRule): 

155 """Require or forbid a trailing punctuation character. Rule name: ``body-full-stop``.""" 

156 

157 @property 

158 def name(self) -> str: 

159 return "body-full-stop" 

160 

161 def validate( 

162 self, commit: CommitMessage, config: RuleConfig 

163 ) -> ValidationError | None: 

164 if not commit.body: 

165 return None 

166 

167 stop_char = config_value_or(config, ".") 

168 ends_with_stop = commit.body.rstrip().endswith(stop_char) 

169 should_end_with_stop = config.condition == RuleCondition.ALWAYS 

170 

171 if ends_with_stop != should_end_with_stop: 

172 msg = ( 

173 f"body must end with '{stop_char}'" 

174 if should_end_with_stop 

175 else f"body may not end with '{stop_char}'" 

176 ) 

177 return self._create_error(config, msg) 

178 return None 

179 

180 

181class BodyCaseRule(BaseRule): 

182 """Enforce a case style on the body text. Rule name: ``body-case``.""" 

183 

184 @property 

185 def name(self) -> str: 

186 return "body-case" 

187 

188 def validate( 

189 self, commit: CommitMessage, config: RuleConfig 

190 ) -> ValidationError | None: 

191 if not commit.body or config.value is None: 

192 return None 

193 

194 case_types = ( 

195 config.value if isinstance(config.value, list) else [config.value] 

196 ) 

197 

198 matches_any = any( 

199 CaseValidator.validate(commit.body, CaseType(case_type)) 

200 for case_type in case_types 

201 ) 

202 

203 should_match = config.condition == RuleCondition.ALWAYS 

204 

205 if matches_any != should_match: 

206 return self._create_error( 

207 config, f"body must match case {config.value}" 

208 ) 

209 return None