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
« 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."""
3from pathlib import Path
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)
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.
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.
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)
47class CommitLinter:
48 """Validates a commit message against a configured rule set.
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 """
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.
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)
75 def lint(self, message: str) -> LintResult:
76 """Validate ``message`` against the configured rules.
78 Merge commits (first line beginning ``Merge ``) are short-circuited
79 as valid without applying any rules.
81 Args:
82 message: Raw commit message text.
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=[])
90 commit = self._parser.parse(message)
91 errors, warnings = self._collect_violations(commit)
92 return LintResult(valid=not errors, errors=errors, warnings=warnings)
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
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)
120 def _is_merge_commit(self, message: str) -> bool:
121 first_line = message.split("\n", 1)[0].strip()
122 return first_line.startswith("Merge ")
125class CommitLinterFactory:
126 """Constructs :class:`CommitLinter` instances with sensible defaults."""
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.
137 Any collaborator can be overridden — useful for tests or for
138 embedding python-commitlint inside a larger toolchain.
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.
147 Returns:
148 A ready-to-use :class:`CommitLinter`.
149 """
150 if parser is None:
151 parser = CommitParserFactory.create()
153 if rule_registry is None:
154 rule_registry = RuleRegistryFactory.create_with_default_rules()
156 if config_loader is None:
157 config_loader = ConfigurationLoaderFactory.create()
159 return CommitLinter(
160 parser=parser,
161 rule_registry=rule_registry,
162 config_loader=config_loader,
163 config_path=config_path,
164 )