Skip to content

Commit 5c85feb

Browse files
feat(customize): add required field for input questions
Users can now mark a cz_customize input question as required by setting required = true in their config. The loader converts this into a questionary validate= callable that rejects empty/whitespace-only answers with the message 'This answer is required.' The required key is stripped before the question dict reaches questionary, so questionary never sees the unknown key. Closes #1231 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4d99415 commit 5c85feb

4 files changed

Lines changed: 124 additions & 2 deletions

File tree

commitizen/cz/customize/customize.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import TYPE_CHECKING, Any
55

66
if TYPE_CHECKING:
7-
from collections.abc import Mapping
7+
from collections.abc import Iterable, Mapping
88

99
from jinja2 import Template
1010

@@ -23,6 +23,13 @@
2323

2424
__all__ = ["CustomizeCommitsCz"]
2525

26+
_REQUIRED_ANSWER_MSG = "This answer is required."
27+
28+
29+
def _required_validator(val: str) -> bool | str:
30+
"""Return True when *val* is non-blank, otherwise an error message."""
31+
return True if val.strip() else _REQUIRED_ANSWER_MSG
32+
2633

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

5259
def questions(self) -> list[CzQuestion]:
53-
return self.custom_settings.get("questions", [{}]) # type: ignore[return-value]
60+
raw_questions: Iterable[CzQuestion] = self.custom_settings.get(
61+
"questions", [{}]
62+
) # type: ignore[assignment]
63+
result: list[CzQuestion] = []
64+
for raw in raw_questions:
65+
q: dict[str, Any] = dict(raw)
66+
if q.get("type") == "input" and q.pop("required", False):
67+
q["validate"] = _required_validator
68+
result.append(q) # type: ignore[arg-type]
69+
return result
5470

5571
def message(self, answers: Mapping[str, Any]) -> str:
5672
message_template = Template(self.custom_settings.get("message_template", ""))

commitizen/question.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ class InputQuestion(TypedDict, total=False):
2121
name: str
2222
message: str
2323
filter: Callable[[str], str]
24+
validate: Callable[[str], bool | str]
25+
required: bool
2426

2527

2628
class ConfirmQuestion(TypedDict):

docs/customization/config_file.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Example:
4545
type = "input"
4646
name = "message"
4747
message = "Body."
48+
required = true
4849

4950
[[tool.commitizen.customize.questions]]
5051
type = "confirm"
@@ -181,6 +182,7 @@ Example:
181182
| `default` | `Any` | `None` | (OPTIONAL) The default value for this question. |
182183
| `filter` | `str` | `None` | (OPTIONAL) Validator for user's answer. **(Work in Progress)** |
183184
| `multiline` | `bool` | `False` | (OPTIONAL) Enable multiline support when `type = input`. |
185+
| `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. |
184186
| `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. |
185187
| `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. |
186188

tests/test_cz_customize.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,3 +609,105 @@ def test_change_type_map(config):
609609
def test_change_type_map_unicode(config_with_unicode):
610610
cz = CustomizeCommitsCz(config_with_unicode)
611611
assert cz.change_type_map == {"✨ feature": "Feat", "🐛 bug fix": "Fix"}
612+
613+
614+
# ---------------------------------------------------------------------------
615+
# Tests for `required` field on input questions
616+
# ---------------------------------------------------------------------------
617+
618+
TOML_WITH_REQUIRED = r"""
619+
[tool.commitizen.customize]
620+
message_template = "{{message}}"
621+
622+
[[tool.commitizen.customize.questions]]
623+
type = "input"
624+
name = "message"
625+
message = "Body."
626+
required = true
627+
628+
[[tool.commitizen.customize.questions]]
629+
type = "input"
630+
name = "optional_note"
631+
message = "Optional note."
632+
"""
633+
634+
TOML_WITHOUT_REQUIRED = r"""
635+
[tool.commitizen.customize]
636+
message_template = "{{message}}"
637+
638+
[[tool.commitizen.customize.questions]]
639+
type = "input"
640+
name = "message"
641+
message = "Body."
642+
"""
643+
644+
645+
def test_required_question_has_validate_callable():
646+
"""A question with required=true should expose a validate callable."""
647+
config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml"))
648+
cz = CustomizeCommitsCz(config)
649+
questions = cz.questions()
650+
651+
required_q = questions[0]
652+
assert "validate" in required_q
653+
assert callable(required_q["validate"])
654+
655+
656+
def test_required_field_is_removed_from_question_dict():
657+
"""The `required` key must not be forwarded to questionary."""
658+
config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml"))
659+
cz = CustomizeCommitsCz(config)
660+
for q in cz.questions():
661+
assert "required" not in q
662+
663+
664+
def test_required_validator_rejects_empty_input():
665+
"""Validator must reject empty and whitespace-only strings."""
666+
config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml"))
667+
cz = CustomizeCommitsCz(config)
668+
validate = cz.questions()[0]["validate"]
669+
670+
assert validate("") is not True
671+
assert validate(" ") is not True
672+
assert isinstance(validate(""), str) # error message
673+
674+
675+
def test_required_validator_accepts_nonempty_input():
676+
"""Validator must accept any non-whitespace-only input."""
677+
config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml"))
678+
cz = CustomizeCommitsCz(config)
679+
validate = cz.questions()[0]["validate"]
680+
681+
assert validate("hello") is True
682+
assert validate(" hi ") is True
683+
684+
685+
def test_optional_question_has_no_validate():
686+
"""Questions without required=true must not get a validate callable."""
687+
config = TomlConfig(data=TOML_WITHOUT_REQUIRED, path=Path("not_exist.toml"))
688+
cz = CustomizeCommitsCz(config)
689+
assert "validate" not in cz.questions()[0]
690+
691+
692+
def test_optional_question_in_mixed_config():
693+
"""Only questions with required=true receive a validator; others are unaffected."""
694+
config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml"))
695+
cz = CustomizeCommitsCz(config)
696+
questions = cz.questions()
697+
698+
required_q = questions[0]
699+
optional_q = questions[1]
700+
701+
assert "validate" in required_q
702+
assert "validate" not in optional_q
703+
704+
705+
def test_required_does_not_mutate_config_settings():
706+
"""Processing questions must not mutate the underlying config data."""
707+
config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml"))
708+
cz = CustomizeCommitsCz(config)
709+
# Call questions() twice; the second call must still work correctly.
710+
cz.questions()
711+
questions = cz.questions()
712+
assert "required" not in questions[0]
713+
assert "validate" in questions[0]

0 commit comments

Comments
 (0)