diff --git a/commitizen/commands/check.py b/commitizen/commands/check.py index 13b8555b6..49bd451b9 100644 --- a/commitizen/commands/check.py +++ b/commitizen/commands/check.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import re import sys from typing import Any @@ -65,7 +64,7 @@ def __call__(self): """Validate if commit messages follows the conventional pattern. Raises: - InvalidCommitMessageError: if the commit provided not follows the conventional pattern + InvalidCommitMessageError: if the commit provided does not follow the conventional pattern """ commits = self._get_commits() if not commits: @@ -73,22 +72,22 @@ def __call__(self): pattern = self.cz.schema_pattern() ill_formated_commits = [ - commit + (commit, check[1]) for commit in commits - if not self.validate_commit_message(commit.message, pattern) + if not ( + check := self.cz.validate_commit_message( + commit.message, + pattern, + allow_abort=self.allow_abort, + allowed_prefixes=self.allowed_prefixes, + max_msg_length=self.max_msg_length, + ) + )[0] ] - displayed_msgs_content = "\n".join( - [ - f'commit "{commit.rev}": "{commit.message}"' - for commit in ill_formated_commits - ] - ) - if displayed_msgs_content: + + if ill_formated_commits: raise InvalidCommitMessageError( - "commit validation: failed!\n" - "please enter a commit message in the commitizen format.\n" - f"{displayed_msgs_content}\n" - f"pattern: {pattern}" + self.cz.format_exception_message(ill_formated_commits) ) out.success("Commit validation: successful!") @@ -139,15 +138,3 @@ def _filter_comments(msg: str) -> str: if not line.startswith("#"): lines.append(line) return "\n".join(lines) - - def validate_commit_message(self, commit_msg: str, pattern: str) -> bool: - if not commit_msg: - return self.allow_abort - - if any(map(commit_msg.startswith, self.allowed_prefixes)): - return True - if self.max_msg_length: - msg_len = len(commit_msg.partition("\n")[0].strip()) - if msg_len > self.max_msg_length: - return False - return bool(re.match(pattern, commit_msg)) diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py index bd116ceb0..07c479ad2 100644 --- a/commitizen/cz/base.py +++ b/commitizen/cz/base.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from abc import ABCMeta, abstractmethod from typing import Any, Callable, Iterable, Protocol @@ -95,6 +96,46 @@ def schema_pattern(self) -> str | None: """Regex matching the schema used for message validation.""" raise NotImplementedError("Not Implemented yet") + def validate_commit_message( + self, + commit_msg: str, + pattern: str | None, + allow_abort: bool, + allowed_prefixes: list[str], + max_msg_length: int, + ) -> tuple[bool, list]: + """Validate commit message against the pattern.""" + if not commit_msg: + return allow_abort, [] + + if pattern is None: + return True, [] + + if any(map(commit_msg.startswith, allowed_prefixes)): + return True, [] + if max_msg_length: + msg_len = len(commit_msg.partition("\n")[0].strip()) + if msg_len > max_msg_length: + return False, [] + return bool(re.match(pattern, commit_msg)), [] + + def format_exception_message( + self, ill_formated_commits: list[tuple[git.GitCommit, list]] + ) -> str: + """Format commit errors.""" + displayed_msgs_content = "\n".join( + [ + f'commit "{commit.rev}": "{commit.message}"' + for commit, _ in ill_formated_commits + ] + ) + return ( + "commit validation: failed!\n" + "please enter a commit message in the commitizen format.\n" + f"{displayed_msgs_content}\n" + f"pattern: {self.schema_pattern}" + ) + def info(self) -> str | None: """Information about the standardized commit message.""" raise NotImplementedError("Not Implemented yet") diff --git a/docs/customization.md b/docs/customization.md index e8f233fce..ac88906ad 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -1,4 +1,4 @@ -Customizing commitizen is not hard at all. +from commitizen import BaseCommitizenCustomizing commitizen is not hard at all. We have two different ways to do so. ## 1. Customize in configuration file @@ -308,6 +308,72 @@ cz -n cz_strange bump [convcomms]: https://github.com/commitizen-tools/commitizen/blob/master/commitizen/cz/conventional_commits/conventional_commits.py +### Custom commit validation and error message + +The commit message validation can be customized by overriding the `validate_commit_message` and `format_error_message` +methods from `BaseCommitizen`. This allows for a more detailed feedback to the user where the error originates from. + +```python +import re + +from commitizen.cz.base import BaseCommitizen +from commitizen import git + + +class CustomValidationCz(BaseCommitizen): + def validate_commit_message( + self, + commit_msg: str, + pattern: str | None, + allow_abort: bool, + allowed_prefixes: list[str], + max_msg_length: int, + ) -> tuple[bool, list]: + """Validate commit message against the pattern.""" + if not commit_msg: + return allow_abort, [] if allow_abort else [f"commit message is empty"] + + if pattern is None: + return True, [] + + if any(map(commit_msg.startswith, allowed_prefixes)): + return True, [] + if max_msg_length: + msg_len = len(commit_msg.partition("\n")[0].strip()) + if msg_len > max_msg_length: + return False, [ + f"commit message is too long. Max length is {max_msg_length}" + ] + pattern_match = re.match(pattern, commit_msg) + if pattern_match: + return True, [] + else: + # Perform additional validation of the commit message format + # and add custom error messages as needed + return False, ["commit message does not match the pattern"] + + def format_exception_message( + self, ill_formated_commits: list[tuple[git.GitCommit, list]] + ) -> str: + """Format commit errors.""" + displayed_msgs_content = "\n".join( + [ + ( + f'commit "{commit.rev}": "{commit.message}"' + f"errors:\n" + "\n".join((f"- {error}" for error in errors)) + ) + for commit, errors in ill_formated_commits + ] + ) + return ( + "commit validation: failed!\n" + "please enter a commit message in the commitizen format.\n" + f"{displayed_msgs_content}\n" + f"pattern: {self.schema_pattern}" + ) +``` + ### Custom changelog generator The changelog generator should just work in a very basic manner without touching anything. diff --git a/tests/commands/test_check_command.py b/tests/commands/test_check_command.py index 57bfe3f10..5ebae9125 100644 --- a/tests/commands/test_check_command.py +++ b/tests/commands/test_check_command.py @@ -452,3 +452,44 @@ def test_check_command_with_message_length_limit_exceeded(config, mocker: MockFi with pytest.raises(InvalidCommitMessageError): check_cmd() error_mock.assert_called_once() + + +@pytest.mark.usefixtures("use_cz_custom_validator") +def test_check_command_with_custom_validator_succeed(mocker: MockFixture, capsys): + testargs = [ + "cz", + "--name", + "cz_custom_validator", + "check", + "--commit-msg-file", + "some_file", + ] + mocker.patch.object(sys, "argv", testargs) + mocker.patch( + "commitizen.commands.check.open", + mocker.mock_open(read_data="ABC-123: add commitizen pre-commit hook"), + ) + cli.main() + out, _ = capsys.readouterr() + assert "Commit validation: successful!" in out + + +@pytest.mark.usefixtures("use_cz_custom_validator") +def test_check_command_with_custom_validator_failed(mocker: MockFixture): + testargs = [ + "cz", + "--name", + "cz_custom_validator", + "check", + "--commit-msg-file", + "some_file", + ] + mocker.patch.object(sys, "argv", testargs) + mocker.patch( + "commitizen.commands.check.open", + mocker.mock_open(read_data="ABC-123 add commitizen pre-commit hook"), + ) + with pytest.raises(InvalidCommitMessageError) as excinfo: + cli.main() + assert "commit validation: failed!" in str(excinfo.value) + assert "commit message does not match pattern" in str(excinfo.value) diff --git a/tests/conftest.py b/tests/conftest.py index cc306ac6d..32089ca7b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ import pytest from pytest_mock import MockerFixture -from commitizen import cmd, defaults +from commitizen import cmd, defaults, git from commitizen.changelog_formats import ( ChangelogFormat, get_changelog_format, @@ -231,6 +231,78 @@ def mock_plugin(mocker: MockerFixture, config: BaseConfig) -> BaseCommitizen: return mock +class ValidationCz(BaseCommitizen): + def questions(self): + return [ + {"type": "input", "name": "commit", "message": "Initial commit:\n"}, + {"type": "input", "name": "issue_nb", "message": "ABC-123"}, + ] + + def message(self, answers: dict): + return f"{answers['issue_nb']}: {answers['commit']}" + + def schema(self): + return ": " + + def schema_pattern(self): + return r"^(?P[A-Z]{3}-\d+): (?P.*)$" + + def validate_commit_message( + self, + commit_msg: str, + pattern: str | None, + allow_abort: bool, + allowed_prefixes: list[str], + max_msg_length: int, + ) -> tuple[bool, list]: + """Validate commit message against the pattern.""" + if not commit_msg: + return allow_abort, [] if allow_abort else ["commit message is empty"] + + if pattern is None: + return True, [] + + if any(map(commit_msg.startswith, allowed_prefixes)): + return True, [] + if max_msg_length: + msg_len = len(commit_msg.partition("\n")[0].strip()) + if msg_len > max_msg_length: + return False, [ + f"commit message is too long. Max length is {max_msg_length}" + ] + pattern_match = bool(re.match(pattern, commit_msg)) + if not pattern_match: + return False, [f"commit message does not match pattern {pattern}"] + return True, [] + + def format_exception_message( + self, ill_formated_commits: list[tuple[git.GitCommit, list]] + ) -> str: + """Format commit errors.""" + displayed_msgs_content = "\n".join( + [ + ( + f'commit "{commit.rev}": "{commit.message}"\n' + f"errors:\n" + "\n".join(f"- {error}" for error in errors) + ) + for (commit, errors) in ill_formated_commits + ] + ) + return ( + "commit validation: failed!\n" + "please enter a commit message in the commitizen format.\n" + f"{displayed_msgs_content}\n" + f"pattern: {self.schema_pattern}" + ) + + +@pytest.fixture +def use_cz_custom_validator(mocker): + new_cz = {**registry, "cz_custom_validator": ValidationCz} + mocker.patch.dict("commitizen.cz.registry", new_cz) + + SUPPORTED_FORMATS = ("markdown", "textile", "asciidoc", "restructuredtext") diff --git a/tests/test_cz_base.py b/tests/test_cz_base.py index 4ee1cc6ed..6553888c4 100644 --- a/tests/test_cz_base.py +++ b/tests/test_cz_base.py @@ -1,3 +1,5 @@ +from typing import Optional + import pytest from commitizen.cz.base import BaseCommitizen @@ -10,6 +12,9 @@ def questions(self): def message(self, answers: dict): return answers["commit"] + def schema_pattern(self) -> Optional[str]: + return None + def test_base_raises_error(config): with pytest.raises(TypeError): @@ -38,6 +43,11 @@ def test_schema(config): cz.schema() +def test_validate_commit_message(config): + cz = DummyCz(config) + assert cz.validate_commit_message("test", None, False, [], 0) == (True, []) + + def test_info(config): cz = DummyCz(config) with pytest.raises(NotImplementedError):