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 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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[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!")

Expand Down Expand Up @@ -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))
41 changes: 41 additions & 0 deletions commitizen/cz/base.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import re
from abc import ABCMeta, abstractmethod
from typing import Any, Callable, Iterable, Protocol

Expand Down Expand Up @@ -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")
Expand Down
68 changes: 67 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 @@ -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.
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)
74 changes: 73 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 "<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,
) -> 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")


Expand Down
10 changes: 10 additions & 0 deletions tests/test_cz_base.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Optional

import pytest

from commitizen.cz.base import BaseCommitizen
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
Loading