Skip to content

Commit

Permalink
feat(commit): implement questions 'filter' support with handlers
Browse files Browse the repository at this point in the history
Supported APIs:
  - multiple_line_breaker
  - required_validator
  - required_validator_scope
  - required_validator_subject_strip
  - required_validator_title_strip

Example YAML configurations:
---
commitizen:
  name: cz_customize
  customize:
    questions:
      - ...
      - type: input
        name: scope
        message: 'Scope of the change :'
        filter: 'required_validator_scope'
        default: ''
      - type: input
        name: subject
        message: 'Title of the commit (starting in lower case and without period) :'
        filter: 'required_validator_subject_strip'
        default: ''
      - type: input
        name: body
        message: 'Additional contextual message (Empty to skip) :'
        default: 'Issue: #...'
        filter: 'multiple_line_breaker'
---

Signed-off-by: Adrian DC <[email protected]>
  • Loading branch information
AdrianDC committed Aug 25, 2024
1 parent 5e08775 commit 314f3bf
Show file tree
Hide file tree
Showing 4 changed files with 185 additions and 8 deletions.
26 changes: 25 additions & 1 deletion commitizen/commands/commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@
from commitizen import factory, git, out
from commitizen.config import BaseConfig
from commitizen.cz.exceptions import CzException
from commitizen.cz.utils import get_backup_file_path
from commitizen.cz.utils import (
get_backup_file_path,
multiple_line_breaker,
required_validator,
required_validator_scope,
required_validator_subject_strip,
required_validator_title_strip,
)
from commitizen.exceptions import (
CommitError,
CommitMessageLengthExceededError,
Expand Down Expand Up @@ -52,6 +59,23 @@ def prompt_commit_questions(self) -> str:

for question in filter(lambda q: q["type"] == "list", questions):
question["use_shortcuts"] = self.config.settings["use_shortcuts"]

for question in filter(
lambda q: isinstance(q.get("filter", None), str), questions
):
if question["filter"] == "multiple_line_breaker":
question["filter"] = multiple_line_breaker
elif question["filter"] == "required_validator":
question["filter"] = required_validator
elif question["filter"] == "required_validator_scope":
question["filter"] = required_validator_scope
elif question["filter"] == "required_validator_subject_strip":
question["filter"] = required_validator_subject_strip
elif question["filter"] == "required_validator_title_strip":
question["filter"] = required_validator_title_strip
else:
raise NotAllowed(f"Unknown value filter: {question['filter']}")

try:
answers = questionary.prompt(questions, style=cz.style)
except ValueError as err:
Expand Down
25 changes: 23 additions & 2 deletions commitizen/cz/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,34 @@
from commitizen.cz import exceptions


def required_validator(answer, msg=None):
def required_validator(answer: str, msg=None) -> str:
if not answer:
raise exceptions.AnswerRequiredError(msg)
return answer


def multiple_line_breaker(answer, sep="|"):
def required_validator_scope(
answer: str,
msg: str = "! Error: Scope is required",
) -> str:
return required_validator(answer, msg)


def required_validator_subject_strip(
answer: str,
msg: str = "! Error: Subject is required",
) -> str:
return required_validator(answer.strip(".").strip(), msg)


def required_validator_title_strip(
answer: str,
msg: str = "! Error: Title is required",
) -> str:
return required_validator(answer.strip(".").strip(), msg)


def multiple_line_breaker(answer: str, sep: str = "|") -> str:
return "\n".join(line.strip() for line in answer.split(sep) if line)


Expand Down
2 changes: 1 addition & 1 deletion docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ commitizen:
| `message` | `str` | `None` | Detail description for the question. |
| `choices` | `list` | `None` | (OPTIONAL) The choices when `type = list`. Either use a list of values or a list of dictionaries with `name` and `value` keys. Keyboard shortcuts can be defined via `key`. See examples above. |
| `default` | `Any` | `None` | (OPTIONAL) The default value for this question. |
| `filter` | `str` | `None` | (Optional) Validator for user's answer. **(Work in Progress)** |
| `filter` | `str` | `None` | (OPTIONAL) Validator for user's answer. The string is the name of a `commitizen.cz.utils.NAME(answer...)` function like `multiple_line_breaker` |
[different-question-types]: https://github.com/tmbo/questionary#different-question-types

#### Shortcut keys
Expand Down
140 changes: 136 additions & 4 deletions tests/test_cz_customize.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import pytest
from pytest_mock import MockFixture

from commitizen import cmd, commands
from commitizen.config import BaseConfig, JsonConfig, TomlConfig, YAMLConfig
from commitizen.cz.customize import CustomizeCommitsCz
from commitizen.exceptions import MissingCzCustomizeConfigError
from commitizen.cz.utils import (
multiple_line_breaker,
required_validator,
required_validator_scope,
required_validator_subject_strip,
required_validator_title_strip,
)
from commitizen.exceptions import MissingCzCustomizeConfigError, NotAllowed

TOML_STR = r"""
[tool.commitizen]
Expand Down Expand Up @@ -36,10 +45,17 @@
]
message = "Select the type of change you are committing"
[[tool.commitizen.customize.questions]]
type = "input"
name = "subject"
message = "Subject."
filter = "required_validator_subject_strip"
[[tool.commitizen.customize.questions]]
type = "input"
name = "message"
message = "Body."
filter = "multiple_line_breaker"
[[tool.commitizen.customize.questions]]
type = "confirm"
Expand Down Expand Up @@ -89,10 +105,17 @@
],
"message": "Select the type of change you are committing"
},
{
"type": "input",
"name": "subject",
"message": "Subject.",
"filter": "required_validator_subject_strip"
},
{
"type": "input",
"name": "message",
"message": "Body."
"message": "Body.",
"filter": "multiple_line_breaker"
},
{
"type": "confirm",
Expand Down Expand Up @@ -139,9 +162,14 @@
- value: bug fix
name: 'bug fix: A bug fix.'
message: Select the type of change you are committing
- type: input
name: subject
message: Subject.
filter: required_validator_subject_strip
- type: input
name: message
message: Body.
filter: multiple_line_breaker
- type: confirm
name: show_message
message: Do you want to add body message in commit?
Expand Down Expand Up @@ -330,6 +358,13 @@
"""


@pytest.fixture
def staging_is_clean(mocker: MockFixture, tmp_git_project):
is_staging_clean_mock = mocker.patch("commitizen.git.is_staging_clean")
is_staging_clean_mock.return_value = False
return tmp_git_project


@pytest.fixture(
params=[
TomlConfig(data=TOML_STR, path="not_exist.toml"),
Expand All @@ -346,6 +381,15 @@ def config(request):
return request.param


@pytest.fixture(
params=[
YAMLConfig(data=YAML_STR, path="not_exist.yaml"),
]
)
def config_filters(request):
return request.param


@pytest.fixture(
params=[
TomlConfig(data=TOML_STR_INFO_PATH, path="not_exist.toml"),
Expand Down Expand Up @@ -437,7 +481,7 @@ def test_change_type_order_unicode(config_with_unicode):
]


def test_questions(config):
def test_questions_default(config):
cz = CustomizeCommitsCz(config)
questions = cz.questions()
expected_questions = [
Expand All @@ -450,7 +494,18 @@ def test_questions(config):
],
"message": "Select the type of change you are committing",
},
{"type": "input", "name": "message", "message": "Body."},
{
"type": "input",
"name": "subject",
"message": "Subject.",
"filter": "required_validator_subject_strip",
},
{
"type": "input",
"name": "message",
"message": "Body.",
"filter": "multiple_line_breaker",
},
{
"type": "confirm",
"name": "show_message",
Expand All @@ -460,6 +515,83 @@ def test_questions(config):
assert list(questions) == expected_questions


@pytest.mark.usefixtures("staging_is_clean")
def test_questions_filter_default(config, mocker: MockFixture):
is_staging_clean_mock = mocker.patch("commitizen.git.is_staging_clean")
is_staging_clean_mock.return_value = False

prompt_mock = mocker.patch("questionary.prompt")
prompt_mock.return_value = {
"change_type": "feature",
"subject": "user created",
"message": "body of the commit",
"show_message": True,
}

commit_mock = mocker.patch("commitizen.git.commit")
commit_mock.return_value = cmd.Command("success", "", b"", b"", 0)

commands.Commit(config, {})()

prompts_questions = prompt_mock.call_args[0][0]
assert prompts_questions[0]["type"] == "list"
assert prompts_questions[0]["name"] == "change_type"
assert prompts_questions[0]["use_shortcuts"] is False
assert prompts_questions[1]["type"] == "input"
assert prompts_questions[1]["name"] == "subject"
assert prompts_questions[1]["filter"] == required_validator_subject_strip
assert prompts_questions[2]["type"] == "input"
assert prompts_questions[2]["name"] == "message"
assert prompts_questions[2]["filter"] == multiple_line_breaker
assert prompts_questions[3]["type"] == "confirm"
assert prompts_questions[3]["name"] == "show_message"


@pytest.mark.usefixtures("staging_is_clean")
def test_questions_filter_values(config_filters, mocker: MockFixture):
is_staging_clean_mock = mocker.patch("commitizen.git.is_staging_clean")
is_staging_clean_mock.return_value = False

prompt_mock = mocker.patch("questionary.prompt")
prompt_mock.return_value = {
"change_type": "feature",
"subject": "user created",
"message": "body of the commit",
"show_message": True,
}

commit_mock = mocker.patch("commitizen.git.commit")
commit_mock.return_value = cmd.Command("success", "", b"", b"", 0)

commit_cmd = commands.Commit(config_filters, {})

assert isinstance(commit_cmd.cz, CustomizeCommitsCz)

for filter_desc in [
("multiple_line_breaker", multiple_line_breaker),
("required_validator", required_validator),
("required_validator_scope", required_validator_scope),
("required_validator_subject_strip", required_validator_subject_strip),
("required_validator_title_strip", required_validator_title_strip),
]:
commit_cmd.cz.custom_settings["questions"][1]["filter"] = filter_desc[0] # type: ignore[index]
commit_cmd()

assert filter_desc[1]("input")

prompts_questions = prompt_mock.call_args[0][0]
assert prompts_questions[1]["filter"] == filter_desc[1]

for filter_name in [
"",
"faulty_value",
]:
commit_cmd.cz.custom_settings["questions"][1]["filter"] = filter_name # type: ignore[index]

with pytest.raises(NotAllowed):
commit_cmd()


def test_questions_unicode(config_with_unicode):
cz = CustomizeCommitsCz(config_with_unicode)
questions = cz.questions()
Expand Down

0 comments on commit 314f3bf

Please sign in to comment.