Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add custom validation #1236

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
41 changes: 14 additions & 27 deletions commitizen/commands/check.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

import os
import re
import sys
from typing import Any

Expand Down Expand Up @@ -65,30 +64,30 @@ 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:
raise NoCommitsFoundError(f"No commit found with range: '{self.rev_range}'")

pattern = self.cz.schema_pattern()
ill_formated_commits = [
commit
(commit, check.errors)
for commit in commits
if not self.validate_commit_message(commit.message, pattern)
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,
)
).is_valid
]
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!")

Expand Down Expand Up @@ -141,15 +140,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))
51 changes: 49 additions & 2 deletions commitizen/cz/base.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from __future__ import annotations

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
Expand All @@ -23,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
Expand All @@ -40,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<message>.*)"
Expand Down Expand Up @@ -95,6 +101,47 @@ def schema_pattern(self) -> str:
"""Regex matching the schema used for message validation."""
raise NotImplementedError("Not Implemented yet")

def validate_commit_message(
self,
benediktziegler marked this conversation as resolved.
Show resolved Hide resolved
*,
commit_msg: str,
pattern: str | None,
allow_abort: bool,
allowed_prefixes: list[str],
max_msg_length: int,
) -> ValidationResult:
"""Validate commit message against the pattern."""
if not commit_msg:
return ValidationResult(allow_abort, [])

if pattern is None:
return ValidationResult(True, [])

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:
return ValidationResult(False, [])
return ValidationResult(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:
"""Information about the standardized commit message."""
raise NotImplementedError("Not Implemented yet")
Expand Down
69 changes: 68 additions & 1 deletion docs/customization.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -309,6 +309,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.
Expand Down
41 changes: 41 additions & 0 deletions tests/commands/test_check_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
82 changes: 80 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@
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 tests.utils import create_file_and_commit

SIGNER = "GitHub Action"
Expand Down Expand Up @@ -238,6 +238,84 @@ 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 "<issue_nb>: <commit>"

def schema_pattern(self):
return r"^(?P<issue_nb>[A-Z]{3}-\d+): (?P<commit>.*)$"

def validate_commit_message(
self,
*,
commit_msg: str,
pattern: str | None,
allow_abort: bool,
allowed_prefixes: list[str],
max_msg_length: int,
) -> ValidationResult:
"""Validate commit message against the pattern."""
if not commit_msg:
return ValidationResult(
allow_abort, [] if allow_abort else ["commit message is empty"]
)

if pattern is None:
return ValidationResult(True, [])

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:
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 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]]
) -> 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")


Expand Down
Loading
Loading