Skip to content

Commit cf6a7ec

Browse files
committed
refactor(question): strict type with pydantic for questions
1 parent a3919c9 commit cf6a7ec

File tree

11 files changed

+94
-29
lines changed

11 files changed

+94
-29
lines changed

commitizen/commands/commit.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
NothingToCommitError,
2525
)
2626
from commitizen.git import smart_open
27+
from commitizen.question import ListQuestion
2728

2829

2930
class Commit:
@@ -52,10 +53,12 @@ def prompt_commit_questions(self) -> str:
5253
# Prompt user for the commit message
5354
cz = self.cz
5455
questions = cz.questions()
55-
for question in filter(lambda q: q["type"] == "list", questions):
56-
question["use_shortcuts"] = self.config.settings["use_shortcuts"]
56+
for question in (q for q in questions if isinstance(q, ListQuestion)):
57+
question.use_shortcuts = self.config.settings["use_shortcuts"]
5758
try:
58-
answers = questionary.prompt(questions, style=cz.style)
59+
answers = questionary.prompt(
60+
(q.model_dump() for q in questions), style=cz.style
61+
)
5962
except ValueError as err:
6063
root_err = err.__context__
6164
if isinstance(root_err, CzException):

commitizen/cz/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from commitizen import git
1111
from commitizen.config.base_config import BaseConfig
12-
from commitizen.defaults import Questions
12+
from commitizen.question import CzQuestion
1313

1414

1515
class MessageBuilderHook(Protocol):
@@ -68,7 +68,7 @@ def __init__(self, config: BaseConfig) -> None:
6868
self.config.settings.update({"style": BaseCommitizen.default_style_config})
6969

7070
@abstractmethod
71-
def questions(self) -> Questions:
71+
def questions(self) -> list[CzQuestion]:
7272
"""Questions regarding the commit message."""
7373

7474
@abstractmethod

commitizen/cz/conventional_commits/conventional_commits.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from commitizen import defaults
55
from commitizen.cz.base import BaseCommitizen
66
from commitizen.cz.utils import multiple_line_breaker, required_validator
7-
from commitizen.defaults import Questions
7+
from commitizen.question import CzQuestion, CzQuestionModel
88

99
__all__ = ["ConventionalCommitsCz"]
1010

@@ -40,8 +40,8 @@ class ConventionalCommitsCz(BaseCommitizen):
4040
}
4141
changelog_pattern = defaults.BUMP_PATTERN
4242

43-
def questions(self) -> Questions:
44-
return [
43+
def questions(self) -> list[CzQuestion]:
44+
questions = [
4545
{
4646
"type": "list",
4747
"name": "prefix",
@@ -146,6 +146,9 @@ def questions(self) -> Questions:
146146
),
147147
},
148148
]
149+
return [
150+
CzQuestionModel.model_validate({"question": q}).question for q in questions
151+
]
149152

150153
def message(self, answers: dict) -> str:
151154
prefix = answers["prefix"]

commitizen/cz/customize/customize.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from typing import TYPE_CHECKING
44

5+
from commitizen.question import CzQuestion, CzQuestionModel
6+
57
if TYPE_CHECKING:
68
from jinja2 import Template
79
else:
@@ -14,7 +16,6 @@
1416
from commitizen import defaults
1517
from commitizen.config import BaseConfig
1618
from commitizen.cz.base import BaseCommitizen
17-
from commitizen.defaults import Questions
1819
from commitizen.exceptions import MissingCzCustomizeConfigError
1920

2021
__all__ = ["CustomizeCommitsCz"]
@@ -45,8 +46,11 @@ def __init__(self, config: BaseConfig):
4546
if value := self.custom_settings.get(attr_name):
4647
setattr(self, attr_name, value)
4748

48-
def questions(self) -> Questions:
49-
return self.custom_settings.get("questions", [{}])
49+
def questions(self) -> list[CzQuestion]:
50+
questions = self.custom_settings.get("questions", [{}])
51+
return [
52+
CzQuestionModel.model_validate({"question": q}).question for q in questions
53+
]
5054

5155
def message(self, answers: dict) -> str:
5256
message_template = Template(self.custom_settings.get("message_template", ""))

commitizen/cz/jira/jira.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import os
22

33
from commitizen.cz.base import BaseCommitizen
4-
from commitizen.defaults import Questions
4+
from commitizen.question import CzQuestion, CzQuestionModel
55

66
__all__ = ["JiraSmartCz"]
77

88

99
class JiraSmartCz(BaseCommitizen):
10-
def questions(self) -> Questions:
11-
return [
10+
def questions(self) -> list[CzQuestion]:
11+
questions = [
1212
{
1313
"type": "input",
1414
"name": "message",
@@ -42,6 +42,9 @@ def questions(self) -> Questions:
4242
"filter": lambda x: "#comment " + x if x else "",
4343
},
4444
]
45+
return [
46+
CzQuestionModel.model_validate({"question": q}).question for q in questions
47+
]
4548

4649
def message(self, answers: dict) -> str:
4750
return " ".join(

commitizen/question.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from typing import Callable, Literal, Optional, Union
2+
3+
from pydantic import BaseModel, Field
4+
5+
6+
class CzModel(BaseModel):
7+
def model_dump(self, **kwargs):
8+
return super().model_dump(exclude_unset=True, **kwargs)
9+
10+
11+
class Choice(CzModel):
12+
value: str
13+
name: str
14+
key: Optional[str] = None
15+
16+
17+
class QuestionBase(CzModel):
18+
name: str
19+
message: str
20+
21+
22+
class ListQuestion(QuestionBase):
23+
type: Literal["list"]
24+
choices: list[Choice]
25+
use_shortcuts: Optional[bool] = None
26+
27+
28+
class SelectQuestion(QuestionBase):
29+
type: Literal["select"]
30+
choices: list[Choice]
31+
use_search_filter: Optional[bool] = None # TODO: confirm type
32+
use_jk_keys: Optional[bool] = None
33+
34+
35+
class InputQuestion(QuestionBase):
36+
type: Literal["input"]
37+
filter: Optional[Callable[[str], str]] = None
38+
39+
40+
class ConfirmQuestion(QuestionBase):
41+
type: Literal["confirm"]
42+
default: Optional[bool] = None
43+
44+
45+
CzQuestion = Union[ListQuestion, SelectQuestion, InputQuestion, ConfirmQuestion]
46+
47+
48+
class CzQuestionModel(CzModel):
49+
question: CzQuestion = Field(discriminator="type")

tests/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from commitizen.config import BaseConfig
1818
from commitizen.cz import registry
1919
from commitizen.cz.base import BaseCommitizen
20+
from commitizen.question import CzQuestion
2021
from tests.utils import create_file_and_commit
2122

2223
SIGNER = "GitHub Action"
@@ -222,7 +223,7 @@ def use_cz_semver(mocker):
222223

223224

224225
class MockPlugin(BaseCommitizen):
225-
def questions(self) -> defaults.Questions:
226+
def questions(self) -> list[CzQuestion]:
226227
return []
227228

228229
def message(self, answers: dict) -> str:

tests/test_cz_conventional_commits.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
parse_subject,
77
)
88
from commitizen.cz.exceptions import AnswerRequiredError
9+
from commitizen.question import ListQuestion
910

1011
valid_scopes = ["", "simple", "dash-separated", "camelCaseUPPERCASE"]
1112

@@ -50,16 +51,15 @@ def test_questions(config):
5051
conventional_commits = ConventionalCommitsCz(config)
5152
questions = conventional_commits.questions()
5253
assert isinstance(questions, list)
53-
assert isinstance(questions[0], dict)
5454

5555

5656
def test_choices_all_have_keyboard_shortcuts(config):
5757
conventional_commits = ConventionalCommitsCz(config)
5858
questions = conventional_commits.questions()
5959

60-
list_questions = (q for q in questions if q["type"] == "list")
60+
list_questions = (q for q in questions if isinstance(q, ListQuestion))
6161
for select in list_questions:
62-
assert all("key" in choice for choice in select["choices"])
62+
assert all(choice.key is not None for choice in select.choices)
6363

6464

6565
def test_small_answer(config):

tests/test_cz_customize.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ def test_questions(config):
443443
"message": "Do you want to add body message in commit?",
444444
},
445445
]
446-
assert list(questions) == expected_questions
446+
assert [q.model_dump() for q in questions] == expected_questions
447447

448448

449449
def test_questions_unicode(config_with_unicode):
@@ -466,7 +466,7 @@ def test_questions_unicode(config_with_unicode):
466466
"message": "Do you want to add body message in commit?",
467467
},
468468
]
469-
assert list(questions) == expected_questions
469+
assert [q.model_dump() for q in questions] == expected_questions
470470

471471

472472
def test_answer(config):

tests/test_cz_jira.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ def test_questions(config):
55
cz = JiraSmartCz(config)
66
questions = cz.questions()
77
assert isinstance(questions, list)
8-
assert isinstance(questions[0], dict)
98

109

1110
def test_answer(config):

tests/test_cz_search_filter.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from commitizen.config import TomlConfig
44
from commitizen.cz.customize import CustomizeCommitsCz
5+
from commitizen.question import SelectQuestion
56

67
TOML_WITH_SEARCH_FILTER = r"""
78
[tool.commitizen]
@@ -48,19 +49,21 @@ def config():
4849
def test_questions_with_search_filter(config):
4950
"""Test that questions are properly configured with search filter"""
5051
cz = CustomizeCommitsCz(config)
51-
questions = cz.questions()
52+
question = cz.questions()[0]
53+
54+
assert isinstance(question, SelectQuestion)
5255

5356
# Test that the first question (change_type) has search filter enabled
54-
assert questions[0]["type"] == "select"
55-
assert questions[0]["name"] == "change_type"
56-
assert questions[0]["use_search_filter"] is True
57-
assert questions[0]["use_jk_keys"] is False
57+
assert question.type == "select"
58+
assert question.name == "change_type"
59+
assert question.use_search_filter is True
60+
assert question.use_jk_keys is False
5861

5962
# Test that the choices are properly configured
60-
choices = questions[0]["choices"]
63+
choices = question.choices
6164
assert len(choices) == 9 # We have 9 commit types
62-
assert choices[0]["value"] == "fix"
63-
assert choices[1]["value"] == "feat"
65+
assert choices[0].value == "fix"
66+
assert choices[1].value == "feat"
6467

6568

6669
def test_message_template(config):

0 commit comments

Comments
 (0)