From 304378b2d7c7938f00b29ce680204d46673be570 Mon Sep 17 00:00:00 2001 From: Adrian DC Date: Sat, 17 Aug 2024 02:24:22 +0200 Subject: [PATCH 1/4] fix(commit): convert 'questions' entries to plain Python dictionaries Details: With a TOML configuration, 'cz_customize' questions are 'tomlkit.items.AoT' / 'tomlkit.items.Table' objects, which only accept TOML-compatible values: assigning Python objects such as callables (required for questionary 'filter' handlers) raises 'tomlkit.exceptions.ConvertError'. Copying each question into a 'dict' also avoids mutating the loaded configuration (for example 'use_shortcuts'). --- Signed-off-by: Adrian DC --- commitizen/commands/commit.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index ecec7f3f6..88c6db2cd 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -69,7 +69,8 @@ def _read_backup_message(self) -> str | None: def _get_message_by_prompt_commit_questions(self) -> str: # Prompt user for the commit message - questions = self.cz.questions() + questions = [dict(question) for question in self.cz.questions()] + for question in (q for q in questions if q["type"] == "list"): question["use_shortcuts"] = self.config.settings["use_shortcuts"] try: From a7dccee8da0fe53dee5a86b9ca17c3ab9f48e8f5 Mon Sep 17 00:00:00 2001 From: Adrian DC Date: Sat, 17 Aug 2024 02:30:44 +0200 Subject: [PATCH 2/4] test(cz_customize): use 'cz_customize' configurations in its tests Details: The JSON and YAML test configurations declared 'cz_jira' and the TOML one had no '[tool.commitizen]' section, so they did not represent real 'cz_customize' configurations; add the missing section and use 'name = "cz_customize"' everywhere --- Signed-off-by: Adrian DC --- tests/test_cz_customize.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/test_cz_customize.py b/tests/test_cz_customize.py index 726177247..1ba5adbbb 100644 --- a/tests/test_cz_customize.py +++ b/tests/test_cz_customize.py @@ -11,6 +11,14 @@ from commitizen.exceptions import MissingCzCustomizeConfigError TOML_STR = r""" + [tool.commitizen] + name = "cz_customize" + version = "1.0.0" + version_files = [ + "commitizen/__version__.py", + "pyproject.toml" + ] + [tool.commitizen.customize] message_template = "{{change_type}}:{% if show_message %} {{message}}{% endif %}" example = "feature: this feature enables customization through a config file" @@ -48,7 +56,7 @@ JSON_STR = r""" { "commitizen": { - "name": "cz_jira", + "name": "cz_customize", "version": "1.0.0", "version_files": [ "commitizen/__version__.py", @@ -105,7 +113,7 @@ YAML_STR = """ commitizen: - name: cz_jira + name: cz_customize version: 1.0.0 version_files: - commitizen/__version__.py From 1d70a093733a60f18158658286e92047e4bb196d Mon Sep 17 00:00:00 2001 From: Adrian DC Date: Tue, 13 Aug 2024 16:01:56 +0200 Subject: [PATCH 3/4] feat(commit): implement questions 'filter' support with handlers Details: The 'filter' key of 'cz_customize' questions is resolved from its string value to the matching 'commitizen.cz.utils' handler before prompting, and unknown values raise 'NotAllowed' --- Supported handlers: - multiple_line_breaker - required_validator - required_validator_scope - required_validator_subject_strip - required_validator_title_strip Example YAML configuration: --- 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 --- commitizen/commands/commit.py | 26 +++++- commitizen/cz/utils.py | 21 +++++ docs/customization/config_file.md | 2 +- tests/test_cz_customize.py | 140 +++++++++++++++++++++++++++++- 4 files changed, 183 insertions(+), 6 deletions(-) diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 88c6db2cd..71ed0a9b1 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -14,7 +14,14 @@ from commitizen import factory, git, out 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, @@ -73,6 +80,23 @@ def _get_message_by_prompt_commit_questions(self) -> str: for question in (q for q in questions if q["type"] == "list"): 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=self.cz.style) except ValueError as err: diff --git a/commitizen/cz/utils.py b/commitizen/cz/utils.py index ba5eace44..5d5e1fa84 100644 --- a/commitizen/cz/utils.py +++ b/commitizen/cz/utils.py @@ -15,6 +15,27 @@ def required_validator(answer: str, msg: object = None) -> str: return answer +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) diff --git a/docs/customization/config_file.md b/docs/customization/config_file.md index 97750e75a..c36999db6 100644 --- a/docs/customization/config_file.md +++ b/docs/customization/config_file.md @@ -179,7 +179,7 @@ Example: | `message` | `str` | `None` | Detail description for the question. | | `choices` | `list` | `None` | (OPTIONAL) The choices when `type = list` or `type = select`. 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` | | `multiline` | `bool` | `False` | (OPTIONAL) Enable multiline support when `type = input`. | | `use_search_filter` | `bool` | `False` | (OPTIONAL) Enable search/filter functionality for list/select type questions. This allows users to type and filter through the choices. | | `use_jk_keys` | `bool` | `True` | (OPTIONAL) Enable/disable j/k keys for navigation in list/select type questions. Set to false if you prefer arrow keys only. | diff --git a/tests/test_cz_customize.py b/tests/test_cz_customize.py index 1ba5adbbb..142e035f8 100644 --- a/tests/test_cz_customize.py +++ b/tests/test_cz_customize.py @@ -2,13 +2,22 @@ from pathlib import Path import pytest +from pytest_mock import MockFixture +from commitizen import cmd, commands from commitizen.config import BaseConfig from commitizen.config.json_config import JsonConfig from commitizen.config.toml_config import TomlConfig from commitizen.config.yaml_config import 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] @@ -42,10 +51,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" @@ -95,10 +111,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", @@ -145,9 +168,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? @@ -336,6 +364,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=Path("not_exist.toml")), @@ -352,6 +387,15 @@ def config(request): return request.param +@pytest.fixture( + params=[ + YAMLConfig(data=YAML_STR, path=Path("not_exist.yaml")), + ] +) +def config_filters(request): + return request.param + + @pytest.fixture( params=[ TomlConfig(data=TOML_STR_INFO_PATH, path=Path("not_exist.toml")), @@ -524,7 +568,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 = [ @@ -537,7 +581,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", @@ -547,6 +602,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() From 6440cdc2a1d44dac7d55226514a48f8889822736 Mon Sep 17 00:00:00 2001 From: Adrian DC Date: Sun, 8 Jun 2025 16:42:08 +0200 Subject: [PATCH 4/4] refactor(utils): rename 'multiple_line_breaker' into 'break_multiple_line' Details: Rename the 'commitizen.cz.utils' handler and update its 'cz_conventional_commits' usage, the questions 'filter' resolution, the documentation and the tests accordingly; configurations and plugins referencing the previous 'multiple_line_breaker' name must be updated --- Signed-off-by: Adrian DC --- commitizen/commands/commit.py | 6 +++--- .../conventional_commits/conventional_commits.py | 4 ++-- commitizen/cz/utils.py | 2 +- docs/customization/config_file.md | 2 +- tests/test_cz_customize.py | 14 +++++++------- tests/test_cz_utils.py | 6 +++--- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 71ed0a9b1..0f06db971 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -15,8 +15,8 @@ from commitizen import factory, git, out from commitizen.cz.exceptions import CzException from commitizen.cz.utils import ( + break_multiple_line, get_backup_file_path, - multiple_line_breaker, required_validator, required_validator_scope, required_validator_subject_strip, @@ -84,8 +84,8 @@ def _get_message_by_prompt_commit_questions(self) -> str: for question in filter( lambda q: isinstance(q.get("filter", None), str), questions ): - if question["filter"] == "multiple_line_breaker": - question["filter"] = multiple_line_breaker + if question["filter"] == "break_multiple_line": + question["filter"] = break_multiple_line elif question["filter"] == "required_validator": question["filter"] = required_validator elif question["filter"] == "required_validator_scope": diff --git a/commitizen/cz/conventional_commits/conventional_commits.py b/commitizen/cz/conventional_commits/conventional_commits.py index 46ad643f8..e28fcf4b6 100644 --- a/commitizen/cz/conventional_commits/conventional_commits.py +++ b/commitizen/cz/conventional_commits/conventional_commits.py @@ -5,7 +5,7 @@ from commitizen import defaults from commitizen.cz.base import BaseCommitizen -from commitizen.cz.utils import multiple_line_breaker, required_validator +from commitizen.cz.utils import break_multiple_line, required_validator if TYPE_CHECKING: from collections.abc import Mapping @@ -136,7 +136,7 @@ def questions(self) -> list[CzQuestion]: "message": ( "Provide additional contextual information about the code changes: (press [enter] to skip)\n" ), - "filter": multiple_line_breaker, + "filter": break_multiple_line, }, { "type": "confirm", diff --git a/commitizen/cz/utils.py b/commitizen/cz/utils.py index 5d5e1fa84..3ef78e56c 100644 --- a/commitizen/cz/utils.py +++ b/commitizen/cz/utils.py @@ -36,7 +36,7 @@ def required_validator_title_strip( return required_validator(answer.strip(".").strip(), msg) -def multiple_line_breaker(answer: str, sep: str = "|") -> str: +def break_multiple_line(answer: str, sep: str = "|") -> str: return "\n".join(line.strip() for line in answer.split(sep) if line) diff --git a/docs/customization/config_file.md b/docs/customization/config_file.md index c36999db6..a57e98b1b 100644 --- a/docs/customization/config_file.md +++ b/docs/customization/config_file.md @@ -179,7 +179,7 @@ Example: | `message` | `str` | `None` | Detail description for the question. | | `choices` | `list` | `None` | (OPTIONAL) The choices when `type = list` or `type = select`. 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. The string is the name of a `commitizen.cz.utils.NAME(answer...)` function like `multiple_line_breaker` | +| `filter` | `str` | `None` | (OPTIONAL) Validator for user's answer. The string is the name of a `commitizen.cz.utils.NAME(answer...)` function like `break_multiple_line` | | `multiline` | `bool` | `False` | (OPTIONAL) Enable multiline support when `type = input`. | | `use_search_filter` | `bool` | `False` | (OPTIONAL) Enable search/filter functionality for list/select type questions. This allows users to type and filter through the choices. | | `use_jk_keys` | `bool` | `True` | (OPTIONAL) Enable/disable j/k keys for navigation in list/select type questions. Set to false if you prefer arrow keys only. | diff --git a/tests/test_cz_customize.py b/tests/test_cz_customize.py index 142e035f8..cb976602e 100644 --- a/tests/test_cz_customize.py +++ b/tests/test_cz_customize.py @@ -11,7 +11,7 @@ from commitizen.config.yaml_config import YAMLConfig from commitizen.cz.customize import CustomizeCommitsCz from commitizen.cz.utils import ( - multiple_line_breaker, + break_multiple_line, required_validator, required_validator_scope, required_validator_subject_strip, @@ -61,7 +61,7 @@ type = "input" name = "message" message = "Body." - filter = "multiple_line_breaker" + filter = "break_multiple_line" [[tool.commitizen.customize.questions]] type = "confirm" @@ -121,7 +121,7 @@ "type": "input", "name": "message", "message": "Body.", - "filter": "multiple_line_breaker" + "filter": "break_multiple_line" }, { "type": "confirm", @@ -175,7 +175,7 @@ - type: input name: message message: Body. - filter: multiple_line_breaker + filter: break_multiple_line - type: confirm name: show_message message: Do you want to add body message in commit? @@ -591,7 +591,7 @@ def test_questions_default(config): "type": "input", "name": "message", "message": "Body.", - "filter": "multiple_line_breaker", + "filter": "break_multiple_line", }, { "type": "confirm", @@ -629,7 +629,7 @@ def test_questions_filter_default(config, mocker: MockFixture): 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[2]["filter"] == break_multiple_line assert prompts_questions[3]["type"] == "confirm" assert prompts_questions[3]["name"] == "show_message" @@ -655,7 +655,7 @@ def test_questions_filter_values(config_filters, mocker: MockFixture): assert isinstance(commit_cmd.cz, CustomizeCommitsCz) for filter_desc in [ - ("multiple_line_breaker", multiple_line_breaker), + ("break_multiple_line", break_multiple_line), ("required_validator", required_validator), ("required_validator_scope", required_validator_scope), ("required_validator_subject_strip", required_validator_subject_strip), diff --git a/tests/test_cz_utils.py b/tests/test_cz_utils.py index 25c960c9a..6f72ef4f5 100644 --- a/tests/test_cz_utils.py +++ b/tests/test_cz_utils.py @@ -11,12 +11,12 @@ def test_required_validator(): utils.required_validator("") -def test_multiple_line_breaker(): +def test_break_multiple_line(): message = "this is the first line | and this is the second line " - result = utils.multiple_line_breaker(message) + result = utils.break_multiple_line(message) assert result == "this is the first line\nand this is the second line" - result = utils.multiple_line_breaker(message, "is") + result = utils.break_multiple_line(message, "is") assert result == "th\n\nthe first line | and th\n\nthe second line"