Skip to content

Commit f66afbc

Browse files
feat: add custom validation
1 parent d6547c1 commit f66afbc

File tree

5 files changed

+236
-29
lines changed

5 files changed

+236
-29
lines changed

Diff for: commitizen/commands/check.py

+14-27
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from __future__ import annotations
22

33
import os
4-
import re
54
import sys
65
from typing import Any
76

@@ -65,30 +64,30 @@ def __call__(self):
6564
"""Validate if commit messages follows the conventional pattern.
6665
6766
Raises:
68-
InvalidCommitMessageError: if the commit provided not follows the conventional pattern
67+
InvalidCommitMessageError: if the commit provided does not follow the conventional pattern
6968
"""
7069
commits = self._get_commits()
7170
if not commits:
7271
raise NoCommitsFoundError(f"No commit found with range: '{self.rev_range}'")
7372

7473
pattern = self.cz.schema_pattern()
7574
ill_formated_commits = [
76-
commit
75+
(commit, check[1])
7776
for commit in commits
78-
if not self.validate_commit_message(commit.message, pattern)
77+
if not (
78+
check := self.cz.validate_commit_message(
79+
commit.message,
80+
pattern,
81+
allow_abort=self.allow_abort,
82+
allowed_prefixes=self.allowed_prefixes,
83+
max_msg_length=self.max_msg_length,
84+
)
85+
)[0]
7986
]
80-
displayed_msgs_content = "\n".join(
81-
[
82-
f'commit "{commit.rev}": "{commit.message}"'
83-
for commit in ill_formated_commits
84-
]
85-
)
86-
if displayed_msgs_content:
87+
88+
if ill_formated_commits:
8789
raise InvalidCommitMessageError(
88-
"commit validation: failed!\n"
89-
"please enter a commit message in the commitizen format.\n"
90-
f"{displayed_msgs_content}\n"
91-
f"pattern: {pattern}"
90+
self.cz.format_exception_message(ill_formated_commits)
9291
)
9392
out.success("Commit validation: successful!")
9493

@@ -139,15 +138,3 @@ def _filter_comments(msg: str) -> str:
139138
if not line.startswith("#"):
140139
lines.append(line)
141140
return "\n".join(lines)
142-
143-
def validate_commit_message(self, commit_msg: str, pattern: str) -> bool:
144-
if not commit_msg:
145-
return self.allow_abort
146-
147-
if any(map(commit_msg.startswith, self.allowed_prefixes)):
148-
return True
149-
if self.max_msg_length:
150-
msg_len = len(commit_msg.partition("\n")[0].strip())
151-
if msg_len > self.max_msg_length:
152-
return False
153-
return bool(re.match(pattern, commit_msg))

Diff for: commitizen/cz/base.py

+41
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import re
34
from abc import ABCMeta, abstractmethod
45
from typing import Any, Callable, Iterable, Protocol
56

@@ -95,6 +96,46 @@ def schema_pattern(self) -> str | None:
9596
"""Regex matching the schema used for message validation."""
9697
raise NotImplementedError("Not Implemented yet")
9798

99+
def validate_commit_message(
100+
self,
101+
commit_msg: str,
102+
pattern: str | None,
103+
allow_abort: bool,
104+
allowed_prefixes: list[str],
105+
max_msg_length: int,
106+
) -> tuple[bool, list]:
107+
"""Validate commit message against the pattern."""
108+
if not commit_msg:
109+
return allow_abort, []
110+
111+
if pattern is None:
112+
return True, []
113+
114+
if any(map(commit_msg.startswith, allowed_prefixes)):
115+
return True, []
116+
if max_msg_length:
117+
msg_len = len(commit_msg.partition("\n")[0].strip())
118+
if msg_len > max_msg_length:
119+
return False, []
120+
return bool(re.match(pattern, commit_msg)), []
121+
122+
def format_exception_message(
123+
self, ill_formated_commits: list[tuple[git.GitCommit, list]]
124+
) -> str:
125+
"""Format commit errors."""
126+
displayed_msgs_content = "\n".join(
127+
[
128+
f'commit "{commit.rev}": "{commit.message}"'
129+
for commit, _ in ill_formated_commits
130+
]
131+
)
132+
return (
133+
"commit validation: failed!\n"
134+
"please enter a commit message in the commitizen format.\n"
135+
f"{displayed_msgs_content}\n"
136+
f"pattern: {self.schema_pattern}"
137+
)
138+
98139
def info(self) -> str | None:
99140
"""Information about the standardized commit message."""
100141
raise NotImplementedError("Not Implemented yet")

Diff for: docs/customization.md

+67-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
Customizing commitizen is not hard at all.
1+
from commitizen import BaseCommitizenCustomizing commitizen is not hard at all.
22
We have two different ways to do so.
33

44
## 1. Customize in configuration file
@@ -308,6 +308,72 @@ cz -n cz_strange bump
308308

309309
[convcomms]: https://github.com/commitizen-tools/commitizen/blob/master/commitizen/cz/conventional_commits/conventional_commits.py
310310

311+
### Custom commit validation and error message
312+
313+
The commit message validation can be customized by overriding the `validate_commit_message` and `format_error_message`
314+
methods from `BaseCommitizen`. This allows for a more detailed feedback to the user where the error originates from.
315+
316+
```python
317+
import re
318+
319+
from commitizen.cz.base import BaseCommitizen
320+
from commitizen import git
321+
322+
323+
class CustomValidationCz(BaseCommitizen):
324+
def validate_commit_message(
325+
self,
326+
commit_msg: str,
327+
pattern: str | None,
328+
allow_abort: bool,
329+
allowed_prefixes: list[str],
330+
max_msg_length: int,
331+
) -> tuple[bool, list]:
332+
"""Validate commit message against the pattern."""
333+
if not commit_msg:
334+
return allow_abort, [] if allow_abort else [f"commit message is empty"]
335+
336+
if pattern is None:
337+
return True, []
338+
339+
if any(map(commit_msg.startswith, allowed_prefixes)):
340+
return True, []
341+
if max_msg_length:
342+
msg_len = len(commit_msg.partition("\n")[0].strip())
343+
if msg_len > max_msg_length:
344+
return False, [
345+
f"commit message is too long. Max length is {max_msg_length}"
346+
]
347+
pattern_match = re.match(pattern, commit_msg)
348+
if pattern_match:
349+
return True, []
350+
else:
351+
# Perform additional validation of the commit message format
352+
# and add custom error messages as needed
353+
return False, ["commit message does not match the pattern"]
354+
355+
def format_exception_message(
356+
self, ill_formated_commits: list[tuple[git.GitCommit, list]]
357+
) -> str:
358+
"""Format commit errors."""
359+
displayed_msgs_content = "\n".join(
360+
[
361+
(
362+
f'commit "{commit.rev}": "{commit.message}"'
363+
f"errors:\n"
364+
"\n".join((f"- {error}" for error in errors))
365+
)
366+
for commit, errors in ill_formated_commits
367+
]
368+
)
369+
return (
370+
"commit validation: failed!\n"
371+
"please enter a commit message in the commitizen format.\n"
372+
f"{displayed_msgs_content}\n"
373+
f"pattern: {self.schema_pattern}"
374+
)
375+
```
376+
311377
### Custom changelog generator
312378

313379
The changelog generator should just work in a very basic manner without touching anything.

Diff for: tests/commands/test_check_command.py

+41
Original file line numberDiff line numberDiff line change
@@ -452,3 +452,44 @@ def test_check_command_with_message_length_limit_exceeded(config, mocker: MockFi
452452
with pytest.raises(InvalidCommitMessageError):
453453
check_cmd()
454454
error_mock.assert_called_once()
455+
456+
457+
@pytest.mark.usefixtures("use_cz_custom_validator")
458+
def test_check_command_with_custom_validator_succeed(mocker: MockFixture, capsys):
459+
testargs = [
460+
"cz",
461+
"--name",
462+
"cz_custom_validator",
463+
"check",
464+
"--commit-msg-file",
465+
"some_file",
466+
]
467+
mocker.patch.object(sys, "argv", testargs)
468+
mocker.patch(
469+
"commitizen.commands.check.open",
470+
mocker.mock_open(read_data="ABC-123: add commitizen pre-commit hook"),
471+
)
472+
cli.main()
473+
out, _ = capsys.readouterr()
474+
assert "Commit validation: successful!" in out
475+
476+
477+
@pytest.mark.usefixtures("use_cz_custom_validator")
478+
def test_check_command_with_custom_validator_failed(mocker: MockFixture):
479+
testargs = [
480+
"cz",
481+
"--name",
482+
"cz_custom_validator",
483+
"check",
484+
"--commit-msg-file",
485+
"some_file",
486+
]
487+
mocker.patch.object(sys, "argv", testargs)
488+
mocker.patch(
489+
"commitizen.commands.check.open",
490+
mocker.mock_open(read_data="ABC-123 add commitizen pre-commit hook"),
491+
)
492+
with pytest.raises(InvalidCommitMessageError) as excinfo:
493+
cli.main()
494+
assert "commit validation: failed!" in str(excinfo.value)
495+
assert "commit message does not match pattern" in str(excinfo.value)

Diff for: tests/conftest.py

+73-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import pytest
1010
from pytest_mock import MockerFixture
1111

12-
from commitizen import cmd, defaults
12+
from commitizen import cmd, defaults, git
1313
from commitizen.changelog_formats import (
1414
ChangelogFormat,
1515
get_changelog_format,
@@ -231,6 +231,78 @@ def mock_plugin(mocker: MockerFixture, config: BaseConfig) -> BaseCommitizen:
231231
return mock
232232

233233

234+
class ValidationCz(BaseCommitizen):
235+
def questions(self):
236+
return [
237+
{"type": "input", "name": "commit", "message": "Initial commit:\n"},
238+
{"type": "input", "name": "issue_nb", "message": "ABC-123"},
239+
]
240+
241+
def message(self, answers: dict):
242+
return f"{answers['issue_nb']}: {answers['commit']}"
243+
244+
def schema(self):
245+
return "<issue_nb>: <commit>"
246+
247+
def schema_pattern(self):
248+
return r"^(?P<issue_nb>[A-Z]{3}-\d+): (?P<commit>.*)$"
249+
250+
def validate_commit_message(
251+
self,
252+
commit_msg: str,
253+
pattern: str | None,
254+
allow_abort: bool,
255+
allowed_prefixes: list[str],
256+
max_msg_length: int,
257+
) -> tuple[bool, list]:
258+
"""Validate commit message against the pattern."""
259+
if not commit_msg:
260+
return allow_abort, [] if allow_abort else ["commit message is empty"]
261+
262+
if pattern is None:
263+
return True, []
264+
265+
if any(map(commit_msg.startswith, allowed_prefixes)):
266+
return True, []
267+
if max_msg_length:
268+
msg_len = len(commit_msg.partition("\n")[0].strip())
269+
if msg_len > max_msg_length:
270+
return False, [
271+
f"commit message is too long. Max length is {max_msg_length}"
272+
]
273+
pattern_match = bool(re.match(pattern, commit_msg))
274+
if not pattern_match:
275+
return False, [f"commit message does not match pattern {pattern}"]
276+
return True, []
277+
278+
def format_exception_message(
279+
self, ill_formated_commits: list[tuple[git.GitCommit, list]]
280+
) -> str:
281+
"""Format commit errors."""
282+
displayed_msgs_content = "\n".join(
283+
[
284+
(
285+
f'commit "{commit.rev}": "{commit.message}"\n'
286+
f"errors:\n"
287+
"\n".join(f"- {error}" for error in errors)
288+
)
289+
for (commit, errors) in ill_formated_commits
290+
]
291+
)
292+
return (
293+
"commit validation: failed!\n"
294+
"please enter a commit message in the commitizen format.\n"
295+
f"{displayed_msgs_content}\n"
296+
f"pattern: {self.schema_pattern}"
297+
)
298+
299+
300+
@pytest.fixture
301+
def use_cz_custom_validator(mocker):
302+
new_cz = {**registry, "cz_custom_validator": ValidationCz}
303+
mocker.patch.dict("commitizen.cz.registry", new_cz)
304+
305+
234306
SUPPORTED_FORMATS = ("markdown", "textile", "asciidoc", "restructuredtext")
235307

236308

0 commit comments

Comments
 (0)