diff --git a/commitizen/commands/check.py b/commitizen/commands/check.py index a6101f7df..d2c0f57bb 100644 --- a/commitizen/commands/check.py +++ b/commitizen/commands/check.py @@ -7,7 +7,6 @@ from commitizen import factory, git, out from commitizen.config import BaseConfig from commitizen.exceptions import ( - CommitMessageLengthExceededError, InvalidCommandArgumentError, InvalidCommitMessageError, NoCommitsFoundError, @@ -81,7 +80,7 @@ def __call__(self) -> None: """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 NoCommitsFoundError: if no commit is found with the given range """ commits = self._get_commits() @@ -89,18 +88,24 @@ def __call__(self) -> None: raise NoCommitsFoundError(f"No commit found with range: '{self.rev_range}'") pattern = re.compile(self.cz.schema_pattern()) - invalid_msgs_content = "\n".join( - f'commit "{commit.rev}": "{commit.message}"' + invalid_commits = [ + (commit, check.errors) for commit in commits - if not self._validate_commit_message(commit.message, pattern, commit.rev) - ) - if invalid_msgs_content: - # TODO: capitalize the first letter of the error message for consistency in v5 + if not ( + check := self.cz.validate_commit_message( + commit_msg=commit.message, + pattern=pattern, + allow_abort=self.allow_abort, + allowed_prefixes=self.allowed_prefixes, + max_msg_length=self.max_msg_length, + commit_hash=commit.rev, + ) + ).is_valid + ] + + if invalid_commits: raise InvalidCommitMessageError( - "commit validation: failed!\n" - "please enter a commit message in the commitizen format.\n" - f"{invalid_msgs_content}\n" - f"pattern: {pattern.pattern}" + self.cz.format_exception_message(invalid_commits) ) out.success("Commit validation: successful!") @@ -155,24 +160,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: re.Pattern[str], commit_hash: 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 is not None: - msg_len = len(commit_msg.partition("\n")[0].strip()) - if msg_len > self.max_msg_length: - raise CommitMessageLengthExceededError( - f"commit validation: failed!\n" - f"commit message length exceeds the limit.\n" - f'commit "{commit_hash}": "{commit_msg}"\n' - f"message length limit: {self.max_msg_length} (actual: {msg_len})" - ) - - return bool(pattern.match(commit_msg)) diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py index 8466b58bb..e3d7d091b 100644 --- a/commitizen/cz/base.py +++ b/commitizen/cz/base.py @@ -1,14 +1,16 @@ from __future__ import annotations +import re from abc import ABCMeta, abstractmethod from collections.abc import Iterable, Mapping -from typing import Any, Callable, Protocol +from typing import Any, Callable, NamedTuple, Protocol from jinja2 import BaseLoader, PackageLoader from prompt_toolkit.styles import Style from commitizen import git from commitizen.config.base_config import BaseConfig +from commitizen.exceptions import CommitMessageLengthExceededError from commitizen.question import CzQuestion @@ -24,6 +26,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 +48,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.*)" @@ -99,3 +106,55 @@ def schema_pattern(self) -> str: @abstractmethod def info(self) -> str: """Information about the standardized commit message.""" + + def validate_commit_message( + self, + *, + commit_msg: str, + pattern: re.Pattern[str], + allow_abort: bool, + allowed_prefixes: list[str], + max_msg_length: int | None, + commit_hash: str, + ) -> ValidationResult: + """Validate commit message against the pattern.""" + if not commit_msg: + return ValidationResult( + allow_abort, [] if allow_abort else ["commit message is empty"] + ) + + if any(map(commit_msg.startswith, allowed_prefixes)): + return ValidationResult(True, []) + + if max_msg_length is not None: + msg_len = len(commit_msg.partition("\n")[0].strip()) + if msg_len > max_msg_length: + # TODO: capitalize the first letter of the error message for consistency in v5 + raise CommitMessageLengthExceededError( + f"commit validation: failed!\n" + f"commit message length exceeds the limit.\n" + f'commit "{commit_hash}": "{commit_msg}"\n' + f"message length limit: {max_msg_length} (actual: {msg_len})" + ) + + return ValidationResult( + bool(pattern.match(commit_msg)), + [f"pattern: {pattern.pattern}"], + ) + + def format_exception_message( + self, invalid_commits: list[tuple[git.GitCommit, list]] + ) -> str: + """Format commit errors.""" + displayed_msgs_content = "\n".join( + [ + f'commit "{commit.rev}": "{commit.message}\n"' + "\n".join(errors) + for commit, errors in invalid_commits + ] + ) + # TODO: capitalize the first letter of the error message for consistency in v5 + return ( + "commit validation: failed!\n" + "please enter a commit message in the commitizen format.\n" + f"{displayed_msgs_content}" + ) diff --git a/docs/commands/bump.md b/docs/commands/bump.md index 510fb4619..cee6c6db2 100644 --- a/docs/commands/bump.md +++ b/docs/commands/bump.md @@ -470,36 +470,65 @@ Supported variables: --- -### `version_files` \* +### `version_files` It is used to identify the files or glob patterns which should be updated with the new version. -It is also possible to provide a pattern for each file, separated by colons (`:`). Commitizen will update its configuration file automatically (`pyproject.toml`, `.cz`) when bumping, regarding if the file is present or not in `version_files`. -\* Renamed from `files` to `version_files`. +You may specify the `version_files` in your `pyproject.toml`, `.cz.toml` or `cz.toml` configuration file. -Some examples +It is also possible to provide a pattern for each file, separated by a colon (e.g. `file:pattern`). See the below example for more details. -`pyproject.toml`, `.cz.toml` or `cz.toml` +#### Example Configuration ```toml title="pyproject.toml" [tool.commitizen] version_files = [ "src/__version__.py", "packages/*/pyproject.toml:version", - "setup.py:version", + "setup.json:version", ] ``` -In the example above, we can see the reference `"setup.py:version"`. -This means that it will find a file `setup.py` and will only make a change -in a line containing the `version` substring. +In the example configuration above, we can see the reference `"setup.json:version"`. + +This means that it will find a file `setup.json` and will only change the lines that contain the substring `"version"`. + +For example, if we have a file `setup.json` with the following content: + + + +```json title="setup.json" +{ + "name": "magictool", + "version": "1.2.3", + "dependencies": { + "lodash": "1.2.3" + } +} +``` + +After running `cz bump 2.0.0`, the file will be updated to: + +```diff title="setup.json" +{ + "name": "magictool", +- "version": "1.2.3", ++ "version": "2.0.0", + "dependencies": { + "lodash": "1.2.3" + } +} +``` !!! note Files can be specified using relative (to the execution) paths, absolute paths, or glob patterns. +!!! note + (Historical note) This option was renamed from `files` to `version_files`. + --- ### `bump_message` diff --git a/docs/customization.md b/docs/customization.md index 99ffd39ba..6ac45843a 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -312,6 +312,73 @@ 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 d2a82a903..b77815a6f 100644 --- a/tests/commands/test_check_command.py +++ b/tests/commands/test_check_command.py @@ -517,3 +517,44 @@ def test_check_command_cli_overrides_config_message_length_limit( config=config, arguments={"message": message, "message_length_limit": None}, ) + + +@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 "pattern: " in str(excinfo.value) diff --git a/tests/conftest.py b/tests/conftest.py index 04a448d99..0377779ea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,18 +5,20 @@ import tempfile from collections.abc import Iterator, Mapping from pathlib import Path +from typing import Any 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, ) from commitizen.config import BaseConfig from commitizen.cz import registry -from commitizen.cz.base import BaseCommitizen +from commitizen.cz.base import BaseCommitizen, ValidationResult +from commitizen.exceptions import CommitMessageLengthExceededError from commitizen.question import CzQuestion from tests.utils import create_file_and_commit @@ -261,6 +263,89 @@ def mock_plugin(mocker: MockerFixture, config: BaseConfig) -> BaseCommitizen: return mock +class ValidationCz(BaseCommitizen): + def questions(self) -> list[CzQuestion]: + return [ + {"type": "input", "name": "commit", "message": "Initial commit:\n"}, + {"type": "input", "name": "issue_nb", "message": "ABC-123"}, + ] + + def message(self, answers: Mapping[str, Any]) -> str: + return f"{answers['issue_nb']}: {answers['commit']}" + + def schema(self) -> str: + return ": " + + def schema_pattern(self) -> str: + return r"^(?P[A-Z]{3}-\d+): (?P.*)$" + + def example(self) -> str: + return "ABC-123: fixed a bug" + + def info(self) -> str: + return "Commit message must start with an issue number like ABC-123" + + def validate_commit_message( + self, + *, + commit_msg: str, + pattern: re.Pattern[str], + allow_abort: bool, + allowed_prefixes: list[str], + max_msg_length: int | None, + commit_hash: str, + ) -> ValidationResult: + """Validate commit message against the pattern.""" + if not commit_msg: + return ValidationResult( + allow_abort, [] if allow_abort else ["commit message is empty"] + ) + + if any(map(commit_msg.startswith, allowed_prefixes)): + return ValidationResult(True, []) + + if max_msg_length: + msg_len = len(commit_msg.partition("\n")[0].strip()) + if msg_len > max_msg_length: + # TODO: capitalize the first letter of the error message for consistency in v5 + raise CommitMessageLengthExceededError( + f"commit validation: failed!\n" + f"commit message length exceeds the limit.\n" + f'commit "{commit_hash}": "{commit_msg}"\n' + f"message length limit: {max_msg_length} (actual: {msg_len})" + ) + + return ValidationResult( + bool(pattern.match(commit_msg)), [f"pattern: {pattern.pattern}"] + ) + + def format_exception_message( + self, invalid_commits: list[tuple[git.GitCommit, list]] + ) -> str: + """Format commit errors.""" + displayed_msgs_content = "\n".join( + [ + ( + f'commit "{commit.rev}": "{commit.message}"\nerrors:\n\n'.join( + f"- {error}" for error in errors + ) + ) + for (commit, errors) in invalid_commits + ] + ) + return ( + "commit validation: failed!\n" + "please enter a commit message in the commitizen format.\n" + f"{displayed_msgs_content}" + ) + + +@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 new file mode 100644 index 000000000..e69de29bb