Coverage for src / python_commitlint / rules / footer_rules.py: 98%

80 statements  

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

1"""Rules that validate the footer block (BREAKING CHANGE, ``token: value``).""" 

2 

3import re 

4 

5from python_commitlint.core.enums import RuleCondition 

6from python_commitlint.core.models import ( 

7 CommitMessage, 

8 RuleConfig, 

9 ValidationError, 

10) 

11from python_commitlint.rules.base import BaseRule, config_value_or 

12 

13_FOOTER_TOKEN_PATTERNS: tuple[re.Pattern[str], ...] = ( 

14 re.compile(r"^BREAKING[- ]CHANGE:"), 

15 re.compile(r"^[\w-]+:\s+"), 

16 re.compile(r"^[\w-]+\s+#\d+"), 

17) 

18 

19 

20def _is_footer_token_line(line: str) -> bool: 

21 return any(pattern.match(line) for pattern in _FOOTER_TOKEN_PATTERNS) 

22 

23 

24class FooterEmptyRule(BaseRule): 

25 """Require or forbid an empty footer. Rule name: ``footer-empty``.""" 

26 

27 @property 

28 def name(self) -> str: 

29 return "footer-empty" 

30 

31 def validate( 

32 self, commit: CommitMessage, config: RuleConfig 

33 ) -> ValidationError | None: 

34 is_empty = not commit.footer 

35 should_be_empty = config.condition == RuleCondition.ALWAYS 

36 

37 if is_empty != should_be_empty: 

38 msg = ( 

39 "footer may not be empty" 

40 if not should_be_empty 

41 else "footer must be empty" 

42 ) 

43 return self._create_error(config, msg) 

44 return None 

45 

46 

47class FooterLeadingBlankRule(BaseRule): 

48 """Require a blank line before the footer. Rule name: ``footer-leading-blank``.""" 

49 

50 @property 

51 def name(self) -> str: 

52 return "footer-leading-blank" 

53 

54 def validate( 

55 self, commit: CommitMessage, config: RuleConfig 

56 ) -> ValidationError | None: 

57 if not commit.footer: 

58 return None 

59 

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

61 footer_start = self._find_footer_start(lines) 

62 

63 if footer_start > 0: 

64 has_blank = not lines[footer_start - 1].strip() 

65 should_have_blank = config.condition == RuleCondition.ALWAYS 

66 

67 if has_blank != should_have_blank: 

68 msg = ( 

69 "footer must have leading blank line" 

70 if should_have_blank 

71 else "footer must not have leading blank line" 

72 ) 

73 return self._create_error(config, msg) 

74 return None 

75 

76 def _find_footer_start(self, lines: list[str]) -> int: 

77 # Skip line 0 (the header) — its `type: subject` shape would 

78 # otherwise be mistaken for a footer token. 

79 for i in range(1, len(lines)): 

80 line = lines[i] 

81 if line and _is_footer_token_line(line): 

82 return i 

83 return -1 

84 

85 

86class FooterMaxLengthRule(BaseRule): 

87 """Enforce a maximum total footer length. Rule name: ``footer-max-length``.""" 

88 

89 @property 

90 def name(self) -> str: 

91 return "footer-max-length" 

92 

93 def validate( 

94 self, commit: CommitMessage, config: RuleConfig 

95 ) -> ValidationError | None: 

96 if not commit.footer: 

97 return None 

98 

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

100 is_valid = len(commit.footer) <= max_length 

101 should_be_valid = config.condition == RuleCondition.ALWAYS 

102 

103 if is_valid != should_be_valid: 

104 return self._create_error( 

105 config, f"footer must be at most {max_length} characters" 

106 ) 

107 return None 

108 

109 

110class FooterMaxLineLengthRule(BaseRule): 

111 """Enforce a per-line footer length limit. Rule name: ``footer-max-line-length``.""" 

112 

113 @property 

114 def name(self) -> str: 

115 return "footer-max-line-length" 

116 

117 def validate( 

118 self, commit: CommitMessage, config: RuleConfig 

119 ) -> ValidationError | None: 

120 if not commit.footer: 

121 return None 

122 

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

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

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

126 if config.condition != RuleCondition.ALWAYS: 

127 return None 

128 

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

130 for line in commit.footer.split("\n"): 

131 if len(line) > max_length: 

132 return self._create_error( 

133 config, 

134 f"footer lines must be at most {max_length} characters", 

135 ) 

136 return None 

137 

138 

139class FooterMinLengthRule(BaseRule): 

140 """Enforce a minimum total footer length. Rule name: ``footer-min-length``.""" 

141 

142 @property 

143 def name(self) -> str: 

144 return "footer-min-length" 

145 

146 def validate( 

147 self, commit: CommitMessage, config: RuleConfig 

148 ) -> ValidationError | None: 

149 if not commit.footer: 

150 return None 

151 

152 min_length = config_value_or(config, 0) 

153 is_valid = len(commit.footer) >= min_length 

154 should_be_valid = config.condition == RuleCondition.ALWAYS 

155 

156 if is_valid != should_be_valid: 

157 return self._create_error( 

158 config, f"footer must be at least {min_length} characters" 

159 ) 

160 return None