Skip to content
Closed
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
20 changes: 18 additions & 2 deletions commitizen/cz/customize/customize.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from collections.abc import Mapping
from collections.abc import Iterable, Mapping

from jinja2 import Template

Expand All @@ -23,6 +23,13 @@

__all__ = ["CustomizeCommitsCz"]

_REQUIRED_ANSWER_MSG = "This answer is required."


def _required_validator(val: str) -> bool | str:
"""Return True when *val* is non-blank, otherwise an error message."""
return True if val.strip() else _REQUIRED_ANSWER_MSG


class CustomizeCommitsCz(BaseCommitizen):
bump_pattern = defaults.BUMP_PATTERN
Expand Down Expand Up @@ -50,7 +57,16 @@ def __init__(self, config: BaseConfig) -> None:
setattr(self, attr_name, value)

def questions(self) -> list[CzQuestion]:
return self.custom_settings.get("questions", [{}]) # type: ignore[return-value]
raw_questions: Iterable[CzQuestion] = self.custom_settings.get(
"questions", [{}]
) # type: ignore[assignment]
result: list[CzQuestion] = []
for raw in raw_questions:
q: dict[str, Any] = dict(raw)
if q.get("type") == "input" and q.pop("required", False):
q["validate"] = _required_validator
Comment on lines +65 to +67
result.append(q) # type: ignore[arg-type]
return result

def message(self, answers: Mapping[str, Any]) -> str:
message_template = Template(self.custom_settings.get("message_template", ""))
Expand Down
2 changes: 2 additions & 0 deletions commitizen/question.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ class InputQuestion(TypedDict, total=False):
name: str
message: str
filter: Callable[[str], str]
validate: Callable[[str], bool | str]
required: bool


class ConfirmQuestion(TypedDict):
Expand Down
2 changes: 2 additions & 0 deletions docs/customization/config_file.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Example:
type = "input"
name = "message"
message = "Body."
required = true

[[tool.commitizen.customize.questions]]
type = "confirm"
Expand Down Expand Up @@ -181,6 +182,7 @@ Example:
| `default` | `Any` | `None` | (OPTIONAL) The default value for this question. |
| `filter` | `str` | `None` | (OPTIONAL) Validator for user's answer. **(Work in Progress)** |
| `multiline` | `bool` | `False` | (OPTIONAL) Enable multiline support when `type = input`. |
| `required` | `bool` | `False` | (OPTIONAL) When `true` and `type = input`, the user cannot submit an empty answer. An error message ("This answer is required.") is displayed until a non-blank value is entered. |
| `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. |

Expand Down
102 changes: 102 additions & 0 deletions tests/test_cz_customize.py
Original file line number Diff line number Diff line change
Expand Up @@ -609,3 +609,105 @@ def test_change_type_map(config):
def test_change_type_map_unicode(config_with_unicode):
cz = CustomizeCommitsCz(config_with_unicode)
assert cz.change_type_map == {"✨ feature": "Feat", "🐛 bug fix": "Fix"}


# ---------------------------------------------------------------------------
# Tests for `required` field on input questions
# ---------------------------------------------------------------------------

TOML_WITH_REQUIRED = r"""
[tool.commitizen.customize]
message_template = "{{message}}"

[[tool.commitizen.customize.questions]]
type = "input"
name = "message"
message = "Body."
required = true

[[tool.commitizen.customize.questions]]
type = "input"
name = "optional_note"
message = "Optional note."
"""

TOML_WITHOUT_REQUIRED = r"""
[tool.commitizen.customize]
message_template = "{{message}}"

[[tool.commitizen.customize.questions]]
type = "input"
name = "message"
message = "Body."
"""


def test_required_question_has_validate_callable():
"""A question with required=true should expose a validate callable."""
config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml"))
cz = CustomizeCommitsCz(config)
questions = cz.questions()

required_q = questions[0]
assert "validate" in required_q
assert callable(required_q["validate"])


def test_required_field_is_removed_from_question_dict():
"""The `required` key must not be forwarded to questionary."""
config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml"))
cz = CustomizeCommitsCz(config)
for q in cz.questions():
assert "required" not in q


def test_required_validator_rejects_empty_input():
"""Validator must reject empty and whitespace-only strings."""
config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml"))
cz = CustomizeCommitsCz(config)
validate = cz.questions()[0]["validate"]

assert validate("") is not True
assert validate(" ") is not True
assert isinstance(validate(""), str) # error message


def test_required_validator_accepts_nonempty_input():
"""Validator must accept any non-whitespace-only input."""
config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml"))
cz = CustomizeCommitsCz(config)
validate = cz.questions()[0]["validate"]

assert validate("hello") is True
assert validate(" hi ") is True


def test_optional_question_has_no_validate():
"""Questions without required=true must not get a validate callable."""
config = TomlConfig(data=TOML_WITHOUT_REQUIRED, path=Path("not_exist.toml"))
cz = CustomizeCommitsCz(config)
assert "validate" not in cz.questions()[0]


def test_optional_question_in_mixed_config():
"""Only questions with required=true receive a validator; others are unaffected."""
config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml"))
cz = CustomizeCommitsCz(config)
questions = cz.questions()

required_q = questions[0]
optional_q = questions[1]

assert "validate" in required_q
assert "validate" not in optional_q


def test_required_does_not_mutate_config_settings():
"""Processing questions must not mutate the underlying config data."""
config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml"))
cz = CustomizeCommitsCz(config)
# Call questions() twice; the second call must still work correctly.
cz.questions()
questions = cz.questions()
assert "required" not in questions[0]
assert "validate" in questions[0]
Loading