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
« 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."""
3import re
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
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*$")
19class BodyEmptyRule(BaseRule):
20 """Require or forbid an empty body. Rule name: ``body-empty``."""
22 @property
23 def name(self) -> str:
24 return "body-empty"
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
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
42class BodyLeadingBlankRule(BaseRule):
43 """Require a blank line between header and body. Rule name: ``body-leading-blank``."""
45 @property
46 def name(self) -> str:
47 return "body-leading-blank"
49 def validate(
50 self, commit: CommitMessage, config: RuleConfig
51 ) -> ValidationError | None:
52 if not commit.body:
53 return None
55 lines = commit.raw.split("\n")
56 if len(lines) < 2:
57 return None
59 has_blank = len(lines) > 1 and not lines[1].strip()
60 should_have_blank = config.condition == RuleCondition.ALWAYS
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
72class BodyMaxLengthRule(BaseRule):
73 """Enforce a maximum total body length. Rule name: ``body-max-length``."""
75 @property
76 def name(self) -> str:
77 return "body-max-length"
79 def validate(
80 self, commit: CommitMessage, config: RuleConfig
81 ) -> ValidationError | None:
82 if not commit.body:
83 return None
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
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
96class BodyMaxLineLengthRule(BaseRule):
97 """Enforce a per-line body length limit. Rule name: ``body-max-line-length``.
99 Lines that consist entirely of a URL are exempt to match upstream commitlint.
100 """
102 @property
103 def name(self) -> str:
104 return "body-max-line-length"
106 def validate(
107 self, commit: CommitMessage, config: RuleConfig
108 ) -> ValidationError | None:
109 if not commit.body:
110 return None
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
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
130class BodyMinLengthRule(BaseRule):
131 """Enforce a minimum total body length. Rule name: ``body-min-length``."""
133 @property
134 def name(self) -> str:
135 return "body-min-length"
137 def validate(
138 self, commit: CommitMessage, config: RuleConfig
139 ) -> ValidationError | None:
140 if not commit.body:
141 return None
143 min_length = config_value_or(config, 0)
144 is_valid = len(commit.body) >= min_length
145 should_be_valid = config.condition == RuleCondition.ALWAYS
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
154class BodyFullStopRule(BaseRule):
155 """Require or forbid a trailing punctuation character. Rule name: ``body-full-stop``."""
157 @property
158 def name(self) -> str:
159 return "body-full-stop"
161 def validate(
162 self, commit: CommitMessage, config: RuleConfig
163 ) -> ValidationError | None:
164 if not commit.body:
165 return None
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
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
181class BodyCaseRule(BaseRule):
182 """Enforce a case style on the body text. Rule name: ``body-case``."""
184 @property
185 def name(self) -> str:
186 return "body-case"
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
194 case_types = (
195 config.value if isinstance(config.value, list) else [config.value]
196 )
198 matches_any = any(
199 CaseValidator.validate(commit.body, CaseType(case_type))
200 for case_type in case_types
201 )
203 should_match = config.condition == RuleCondition.ALWAYS
205 if matches_any != should_match:
206 return self._create_error(
207 config, f"body must match case {config.value}"
208 )
209 return None