Coverage for src / python_commitlint / linter.py: 100%

51 statements  

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

1"""The commit linter — orchestrates parsing, rule dispatch, and aggregation.""" 

2 

3from pathlib import Path 

4 

5from python_commitlint.config.configuration import ( 

6 ConfigurationLoaderFactory, 

7) 

8from python_commitlint.core.enums import Severity 

9from python_commitlint.core.models import ( 

10 CommitMessage, 

11 Configuration, 

12 LintResult, 

13 RuleConfig, 

14 ValidationError, 

15) 

16from python_commitlint.core.protocols import ( 

17 CommitParserProtocol, 

18 ConfigurationLoaderProtocol, 

19) 

20from python_commitlint.parser import CommitParserFactory 

21from python_commitlint.rules.registry import ( 

22 RuleRegistry, 

23 RuleRegistryFactory, 

24) 

25 

26 

27def _categorize_violation( 

28 violation: ValidationError, 

29 errors: list[ValidationError], 

30 warnings: list[ValidationError], 

31) -> None: 

32 """Append a violation to the matching bucket based on severity. 

33 

34 Args: 

35 violation: The validation error produced by a rule. 

36 errors: Mutable list that receives ``ERROR``-severity violations. 

37 warnings: Mutable list that receives ``WARNING``-severity violations. 

38 

39 DISABLED violations are ignored — neither bucket receives them. 

40 """ 

41 if violation.severity == Severity.ERROR: 

42 errors.append(violation) 

43 elif violation.severity == Severity.WARNING: 

44 warnings.append(violation) 

45 

46 

47class CommitLinter: 

48 """Validates a commit message against a configured rule set. 

49 

50 The linter is constructed with all of its collaborators (parser, rule 

51 registry, configuration loader) injected via :class:`CommitLinterFactory` 

52 so it can be tested in isolation. Configuration is loaded eagerly in 

53 ``__init__`` so the linter is reentrant. 

54 """ 

55 

56 def __init__( 

57 self, 

58 parser: CommitParserProtocol, 

59 rule_registry: RuleRegistry, 

60 config_loader: ConfigurationLoaderProtocol, 

61 config_path: Path | None = None, 

62 ) -> None: 

63 """Initialize the linter and load its configuration. 

64 

65 Args: 

66 parser: Parser used to turn raw messages into :class:`CommitMessage`. 

67 rule_registry: Registry the linter uses to look rules up by name. 

68 config_loader: Loader that produces the resolved configuration. 

69 config_path: Optional explicit configuration path. 

70 """ 

71 self._parser = parser 

72 self._rule_registry = rule_registry 

73 self._configuration: Configuration = config_loader.load(config_path) 

74 

75 def lint(self, message: str) -> LintResult: 

76 """Validate ``message`` against the configured rules. 

77 

78 Merge commits (first line beginning ``Merge ``) are short-circuited 

79 as valid without applying any rules. 

80 

81 Args: 

82 message: Raw commit message text. 

83 

84 Returns: 

85 A :class:`LintResult` summarizing errors, warnings, and validity. 

86 """ 

87 if self._is_merge_commit(message): 

88 return LintResult(valid=True, errors=[], warnings=[]) 

89 

90 commit = self._parser.parse(message) 

91 errors, warnings = self._collect_violations(commit) 

92 return LintResult(valid=not errors, errors=errors, warnings=warnings) 

93 

94 def _collect_violations( 

95 self, commit: CommitMessage 

96 ) -> tuple[list[ValidationError], list[ValidationError]]: 

97 errors: list[ValidationError] = [] 

98 warnings: list[ValidationError] = [] 

99 for rule_name, rule_config in self._configuration.rules.items(): 

100 self._apply_rule(rule_name, rule_config, commit, errors, warnings) 

101 return errors, warnings 

102 

103 def _apply_rule( 

104 self, 

105 rule_name: str, 

106 rule_config: RuleConfig, 

107 commit: CommitMessage, 

108 errors: list[ValidationError], 

109 warnings: list[ValidationError], 

110 ) -> None: 

111 if rule_config.severity == Severity.DISABLED: 

112 return 

113 rule = self._rule_registry.get(rule_name) 

114 if not rule: 

115 return 

116 violation = rule.validate(commit, rule_config) 

117 if violation: 

118 _categorize_violation(violation, errors, warnings) 

119 

120 def _is_merge_commit(self, message: str) -> bool: 

121 first_line = message.split("\n", 1)[0].strip() 

122 return first_line.startswith("Merge ") 

123 

124 

125class CommitLinterFactory: 

126 """Constructs :class:`CommitLinter` instances with sensible defaults.""" 

127 

128 @staticmethod 

129 def create( 

130 parser: CommitParserProtocol | None = None, 

131 rule_registry: RuleRegistry | None = None, 

132 config_loader: ConfigurationLoaderProtocol | None = None, 

133 config_path: Path | None = None, 

134 ) -> CommitLinter: 

135 """Build a :class:`CommitLinter` wired with default collaborators. 

136 

137 Any collaborator can be overridden — useful for tests or for 

138 embedding python-commitlint inside a larger toolchain. 

139 

140 Args: 

141 parser: Optional parser; defaults to :class:`ConventionalCommitParser`. 

142 rule_registry: Optional registry; defaults to one populated with 

143 every built-in rule. 

144 config_loader: Optional loader; defaults to :class:`ConfigurationLoader`. 

145 config_path: Optional explicit path passed through to the loader. 

146 

147 Returns: 

148 A ready-to-use :class:`CommitLinter`. 

149 """ 

150 if parser is None: 

151 parser = CommitParserFactory.create() 

152 

153 if rule_registry is None: 

154 rule_registry = RuleRegistryFactory.create_with_default_rules() 

155 

156 if config_loader is None: 

157 config_loader = ConfigurationLoaderFactory.create() 

158 

159 return CommitLinter( 

160 parser=parser, 

161 rule_registry=rule_registry, 

162 config_loader=config_loader, 

163 config_path=config_path, 

164 )