From f66afbca55affba7a03bc9934ebad75dba2144ea Mon Sep 17 00:00:00 2001 From: Benedikt Ziegler Date: Fri, 13 Sep 2024 13:21:03 +0200 Subject: [PATCH 1/6] feat: add custom validation --- commitizen/commands/check.py | 41 ++++++--------- commitizen/cz/base.py | 41 +++++++++++++++ docs/customization.md | 68 ++++++++++++++++++++++++- tests/commands/test_check_command.py | 41 +++++++++++++++ tests/conftest.py | 74 +++++++++++++++++++++++++++- 5 files changed, 236 insertions(+), 29 deletions(-) diff --git a/commitizen/commands/check.py b/commitizen/commands/check.py index 13b8555b6d..49bd451b98 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 bd116ceb02..07c479ad2c 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 e8f233fce1..ac88906adf 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 57bfe3f10a..5ebae9125a 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 cc306ac6d4..32089ca7bf 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") From 7bd16c3456216adecf2b317b97aab981cbf105bb Mon Sep 17 00:00:00 2001 From: Benedikt Ziegler Date: Fri, 13 Sep 2024 15:08:35 +0200 Subject: [PATCH 2/6] test: add test to cover schema_pattern is None case --- tests/test_cz_base.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_cz_base.py b/tests/test_cz_base.py index 4ee1cc6eda..6553888c41 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): From 8eaf37c980312c0fd10c36f10685b14f717cb7f4 Mon Sep 17 00:00:00 2001 From: Benedikt Ziegler Date: Wed, 9 Oct 2024 13:34:29 +0200 Subject: [PATCH 3/6] fix: fix method call --- commitizen/cz/base.py | 2 +- docs/customization.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py index 07c479ad2c..cf723f0be6 100644 --- a/commitizen/cz/base.py +++ b/commitizen/cz/base.py @@ -133,7 +133,7 @@ def format_exception_message( "commit validation: failed!\n" "please enter a commit message in the commitizen format.\n" f"{displayed_msgs_content}\n" - f"pattern: {self.schema_pattern}" + f"pattern: {self.schema_pattern()}" ) def info(self) -> str | None: diff --git a/docs/customization.md b/docs/customization.md index ac88906adf..820a5b65c0 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -370,7 +370,7 @@ class CustomValidationCz(BaseCommitizen): "commit validation: failed!\n" "please enter a commit message in the commitizen format.\n" f"{displayed_msgs_content}\n" - f"pattern: {self.schema_pattern}" + f"pattern: {self.schema_pattern()}" ) ``` From 784e3b4c67cbf7c45720923fb10e2d7a3da94ff7 Mon Sep 17 00:00:00 2001 From: Benedikt Ziegler Date: Mon, 18 Nov 2024 14:32:43 +0100 Subject: [PATCH 4/6] refactor: make arguments keyword only --- commitizen/commands/check.py | 4 ++-- commitizen/cz/base.py | 1 + docs/customization.md | 1 + tests/conftest.py | 1 + tests/test_cz_base.py | 8 +++++++- 5 files changed, 12 insertions(+), 3 deletions(-) diff --git a/commitizen/commands/check.py b/commitizen/commands/check.py index 49bd451b98..3d28273c0b 100644 --- a/commitizen/commands/check.py +++ b/commitizen/commands/check.py @@ -76,8 +76,8 @@ def __call__(self): for commit in commits if not ( check := self.cz.validate_commit_message( - commit.message, - pattern, + commit_msg=commit.message, + pattern=pattern, allow_abort=self.allow_abort, allowed_prefixes=self.allowed_prefixes, max_msg_length=self.max_msg_length, diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py index cf723f0be6..5cf1136913 100644 --- a/commitizen/cz/base.py +++ b/commitizen/cz/base.py @@ -98,6 +98,7 @@ def schema_pattern(self) -> str | None: def validate_commit_message( self, + *, commit_msg: str, pattern: str | None, allow_abort: bool, diff --git a/docs/customization.md b/docs/customization.md index 820a5b65c0..754d5c90a3 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -323,6 +323,7 @@ from commitizen import git class CustomValidationCz(BaseCommitizen): def validate_commit_message( self, + *, commit_msg: str, pattern: str | None, allow_abort: bool, diff --git a/tests/conftest.py b/tests/conftest.py index 32089ca7bf..ce6e51956e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -249,6 +249,7 @@ def schema_pattern(self): def validate_commit_message( self, + *, commit_msg: str, pattern: str | None, allow_abort: bool, diff --git a/tests/test_cz_base.py b/tests/test_cz_base.py index 6553888c41..ce1e4a09e7 100644 --- a/tests/test_cz_base.py +++ b/tests/test_cz_base.py @@ -45,7 +45,13 @@ def test_schema(config): def test_validate_commit_message(config): cz = DummyCz(config) - assert cz.validate_commit_message("test", None, False, [], 0) == (True, []) + assert cz.validate_commit_message( + commit_msg="test", + pattern=None, + allow_abort=False, + allowed_prefixes=[], + max_msg_length=0, + ) == (True, []) def test_info(config): From 904f200ff74eb10272be9b4669297830efb40117 Mon Sep 17 00:00:00 2001 From: Benedikt Ziegler Date: Mon, 18 Nov 2024 14:34:37 +0100 Subject: [PATCH 5/6] refactor: use namedtuple for return --- commitizen/commands/check.py | 4 ++-- commitizen/cz/base.py | 21 +++++++++++++-------- tests/conftest.py | 25 +++++++++++++++---------- tests/test_cz_base.py | 4 ++-- 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/commitizen/commands/check.py b/commitizen/commands/check.py index 3d28273c0b..328b208971 100644 --- a/commitizen/commands/check.py +++ b/commitizen/commands/check.py @@ -72,7 +72,7 @@ def __call__(self): pattern = self.cz.schema_pattern() ill_formated_commits = [ - (commit, check[1]) + (commit, check.errors) for commit in commits if not ( check := self.cz.validate_commit_message( @@ -82,7 +82,7 @@ def __call__(self): allowed_prefixes=self.allowed_prefixes, max_msg_length=self.max_msg_length, ) - )[0] + ).is_valid ] if ill_formated_commits: diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py index 5cf1136913..5854083b75 100644 --- a/commitizen/cz/base.py +++ b/commitizen/cz/base.py @@ -2,7 +2,7 @@ import re from abc import ABCMeta, abstractmethod -from typing import Any, Callable, Iterable, Protocol +from typing import Any, Callable, Iterable, NamedTuple, Protocol from jinja2 import BaseLoader, PackageLoader from prompt_toolkit.styles import Style, merge_styles @@ -24,6 +24,11 @@ def __call__( ) -> dict[str, Any]: ... +class ValidationResult(NamedTuple): + is_valid: bool + errors: list + + class BaseCommitizen(metaclass=ABCMeta): bump_pattern: str | None = None bump_map: dict[str, str] | None = None @@ -41,7 +46,7 @@ class BaseCommitizen(metaclass=ABCMeta): ("disabled", "fg:#858585 italic"), ] - # The whole subject will be parsed as message by default + # The whole subject will be parsed as a message by default # This allows supporting changelog for any rule system. # It can be modified per rule commit_parser: str | None = r"(?P.*)" @@ -104,21 +109,21 @@ def validate_commit_message( allow_abort: bool, allowed_prefixes: list[str], max_msg_length: int, - ) -> tuple[bool, list]: + ) -> ValidationResult: """Validate commit message against the pattern.""" if not commit_msg: - return allow_abort, [] + return ValidationResult(allow_abort, []) if pattern is None: - return True, [] + return ValidationResult(True, []) if any(map(commit_msg.startswith, allowed_prefixes)): - return True, [] + return ValidationResult(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)), [] + return ValidationResult(False, []) + return ValidationResult(bool(re.match(pattern, commit_msg)), []) def format_exception_message( self, ill_formated_commits: list[tuple[git.GitCommit, list]] diff --git a/tests/conftest.py b/tests/conftest.py index ce6e51956e..d30c1f48b8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,7 +16,7 @@ ) from commitizen.config import BaseConfig from commitizen.cz import registry -from commitizen.cz.base import BaseCommitizen +from commitizen.cz.base import BaseCommitizen, ValidationResult from tests.utils import create_file_and_commit SIGNER = "GitHub Action" @@ -255,26 +255,31 @@ def validate_commit_message( allow_abort: bool, allowed_prefixes: list[str], max_msg_length: int, - ) -> tuple[bool, list]: + ) -> ValidationResult: """Validate commit message against the pattern.""" if not commit_msg: - return allow_abort, [] if allow_abort else ["commit message is empty"] + return ValidationResult( + allow_abort, [] if allow_abort else ["commit message is empty"] + ) if pattern is None: - return True, [] + return ValidationResult(True, []) if any(map(commit_msg.startswith, allowed_prefixes)): - return True, [] + return ValidationResult(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}" - ] + return ValidationResult( + 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, [] + return ValidationResult( + False, [f"commit message does not match pattern {pattern}"] + ) + return ValidationResult(True, []) def format_exception_message( self, ill_formated_commits: list[tuple[git.GitCommit, list]] diff --git a/tests/test_cz_base.py b/tests/test_cz_base.py index ce1e4a09e7..3f4029c5e3 100644 --- a/tests/test_cz_base.py +++ b/tests/test_cz_base.py @@ -2,7 +2,7 @@ import pytest -from commitizen.cz.base import BaseCommitizen +from commitizen.cz.base import BaseCommitizen, ValidationResult class DummyCz(BaseCommitizen): @@ -51,7 +51,7 @@ def test_validate_commit_message(config): allow_abort=False, allowed_prefixes=[], max_msg_length=0, - ) == (True, []) + ) == ValidationResult(True, []) def test_info(config): From ff7eee588d5a9f744998a360e318a60796d4009e Mon Sep 17 00:00:00 2001 From: Benedikt Ziegler Date: Mon, 18 Nov 2024 14:53:17 +0100 Subject: [PATCH 6/6] fix: fix type mismatch from merge --- tests/test_cz_base.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_cz_base.py b/tests/test_cz_base.py index 3f4029c5e3..76f82ac635 100644 --- a/tests/test_cz_base.py +++ b/tests/test_cz_base.py @@ -1,5 +1,3 @@ -from typing import Optional - import pytest from commitizen.cz.base import BaseCommitizen, ValidationResult @@ -12,8 +10,8 @@ def questions(self): def message(self, answers: dict): return answers["commit"] - def schema_pattern(self) -> Optional[str]: - return None + def schema_pattern(self) -> str: + return ".*" def test_base_raises_error(config):