Skip to content

Commit 7f97628

Browse files
committed
feat(choices): support questionary checkbox for multiple choices using multiselect: true.
Fixes copier-org#218
1 parent 9ea980f commit 7f97628

File tree

3 files changed

+198
-6
lines changed

3 files changed

+198
-6
lines changed

copier/user_data.py

+28-5
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,10 @@ class Question:
127127
Selections available for the user if the question requires them.
128128
Can be templated.
129129
130+
multiselect:
131+
Indicates if the question supports multiple answers.
132+
Only supported by choices type.
133+
130134
default:
131135
Default value presented to the user to make it easier to respond.
132136
Can be templated.
@@ -173,6 +177,7 @@ class Question:
173177
answers: AnswersMap
174178
jinja_env: SandboxedEnvironment
175179
choices: Union[Sequence[Any], Dict[Any, Any]] = field(default_factory=list)
180+
multiselect: bool = False
176181
default: Any = MISSING
177182
help: str = ""
178183
multiline: Union[str, bool] = False
@@ -215,6 +220,8 @@ def cast_answer(self, answer: Any) -> Any:
215220
f'to question "{self.var_name}" of type "{type_name}"'
216221
)
217222
try:
223+
if self.multiselect and isinstance(answer, list):
224+
return [type_fn(item) for item in answer]
218225
return type_fn(answer)
219226
except (TypeError, AttributeError) as error:
220227
# JSON or YAML failed because it wasn't a string; no need to convert
@@ -253,9 +260,11 @@ def get_default_rendered(self) -> Union[bool, str, Choice, None, MissingType]:
253260
return MISSING
254261
# If there are choices, return the one that matches the expressed default
255262
if self.choices:
256-
for choice in self._formatted_choices:
257-
if choice.value == default:
258-
return choice
263+
# questionary checkbox use Choice.checked for multiple default
264+
if not self.multiselect:
265+
for choice in self._formatted_choices:
266+
if choice.value == default:
267+
return choice
259268
return None
260269
# Yes/No questions expect and return bools
261270
if isinstance(default, bool) and self.get_type_name() == "bool":
@@ -278,6 +287,7 @@ def _formatted_choices(self) -> Sequence[Choice]:
278287
"""Obtain choices rendered and properly formatted."""
279288
result = []
280289
choices = self.choices
290+
default = self.get_default()
281291
if isinstance(self.choices, dict):
282292
choices = list(self.choices.items())
283293
for choice in choices:
@@ -297,11 +307,18 @@ def _formatted_choices(self) -> Sequence[Choice]:
297307
raise KeyError("Property 'value' is required")
298308
if "validator" in value and not isinstance(value["validator"], str):
299309
raise ValueError("Property 'validator' must be a string")
310+
300311
disabled = self.render_value(value.get("validator", ""))
301312
value = value["value"]
302313
# The value can be templated
303314
value = self.render_value(value)
304-
c = Choice(name, value, disabled=disabled)
315+
checked = (
316+
self.multiselect
317+
and isinstance(default, list)
318+
and self.cast_answer(value) in default
319+
or None
320+
)
321+
c = Choice(name, value, disabled=disabled, checked=checked)
305322
# Try to cast the value according to the question's type to raise
306323
# an error in case the value is incompatible.
307324
self.cast_answer(c.value)
@@ -347,7 +364,7 @@ def get_questionary_structure(self) -> AnyByStrDict:
347364
if default is MISSING:
348365
result["default"] = False
349366
if self.choices:
350-
questionary_type = "select"
367+
questionary_type = "checkbox" if self.multiselect else "select"
351368
result["choices"] = self._formatted_choices
352369
if questionary_type == "input":
353370
if self.secret:
@@ -419,6 +436,12 @@ def render_value(
419436

420437
def parse_answer(self, answer: Any) -> Any:
421438
"""Parse the answer according to the question's type."""
439+
if self.multiselect:
440+
return [self._parse_answer(a) for a in answer]
441+
return self._parse_answer(answer)
442+
443+
def _parse_answer(self, answer: Any) -> Any:
444+
"""Parse a single answer according to the question's type."""
422445
ans = self.cast_answer(answer)
423446
choices = self._formatted_choices
424447
if not choices:

docs/configuring.md

+2
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ Supported keys:
150150
Some array: "[str, keeps, this, as, a, str]"
151151
```
152152

153+
- **multiselect**: When set to `true`, allows multiple choices. The answer will be a
154+
`list[T]` instead of a `T` where `T` is of type `type`.
153155
- **default**: Leave empty to force the user to answer. Provide a default to save them
154156
from typing it if it's quite common. When using `choices`, the default must be the
155157
choice _value_, not its _key_, and it must match its _type_. If values are quite

tests/test_prompt.py

+168-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from pathlib import Path
2-
from typing import Any, Dict, List, Mapping, Tuple, Union
2+
from typing import Any, Dict, List, Mapping, Protocol, Tuple, Union
33

44
import pexpect
55
import pytest
66
import yaml
7+
from pexpect.popen_spawn import PopenSpawn
78
from plumbum import local
89
from plumbum.cmd import git
910

@@ -21,6 +22,12 @@
2122
git_save,
2223
)
2324

25+
try:
26+
from typing import TypeAlias # type: ignore[attr-defined]
27+
except ImportError:
28+
from typing_extensions import TypeAlias
29+
30+
2431
MARIO_TREE: Mapping[StrOrPath, Union[str, bytes]] = {
2532
"copier.yml": (
2633
f"""\
@@ -785,3 +792,163 @@ def test_required_choice_question(
785792
"_src_path": str(src),
786793
"question": expected_answer,
787794
}
795+
796+
797+
QuestionType: TypeAlias = str
798+
QuestionChoices: TypeAlias = Union[List[Any], Dict[str, Any]]
799+
ParsedValues: TypeAlias = List[Any]
800+
801+
_CHOICES: Dict[str, Tuple[QuestionType, QuestionChoices, ParsedValues]] = {
802+
"str": ("str", ["one", "two", "three"], ["one", "two", "three"]),
803+
"int": ("int", [1, 2, 3], [1, 2, 3]),
804+
"int-label-list": ("int", [["one", 1], ["two", 2], ["three", 3]], [1, 2, 3]),
805+
"int-label-dict": ("int", {"1. one": 1, "2. two": 2, "3. three": 3}, [1, 2, 3]),
806+
"float": ("float", [1.0, 2.0, 3.0], [1.0, 2.0, 3.0]),
807+
"json": ("json", ["[1]", "[2]", "[3]"], [[1], [2], [3]]),
808+
"yaml": ("yaml", ["- 1", "- 2", "- 3"], [[1], [2], [3]]),
809+
}
810+
CHOICES = [pytest.param(*specs, id=id) for id, specs in _CHOICES.items()]
811+
812+
813+
class QuestionTreeFixture(Protocol):
814+
def __call__(self, **kwargs) -> Tuple[Path, Path]:
815+
...
816+
817+
818+
@pytest.fixture
819+
def question_tree(tmp_path_factory: pytest.TempPathFactory) -> QuestionTreeFixture:
820+
def builder(**question) -> Tuple[Path, Path]:
821+
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
822+
build_file_tree(
823+
{
824+
(src / "copier.yml"): yaml.dump(
825+
{
826+
"_envops": BRACKET_ENVOPS,
827+
"_templates_suffix": SUFFIX_TMPL,
828+
"question": question,
829+
}
830+
),
831+
(src / "[[ _copier_conf.answers_file ]].tmpl"): (
832+
"[[ _copier_answers|to_nice_yaml ]]"
833+
),
834+
}
835+
)
836+
return src, dst
837+
838+
return builder
839+
840+
841+
class CopierFixture(Protocol):
842+
def __call__(self, *args, **kwargs) -> PopenSpawn:
843+
...
844+
845+
846+
@pytest.fixture
847+
def copier(spawn: Spawn) -> CopierFixture:
848+
"""Multiple choices are properly remembered and selected in TUI when updating."""
849+
850+
def fixture(*args, **kwargs) -> PopenSpawn:
851+
return spawn(COPIER_PATH + args, **kwargs)
852+
853+
return fixture
854+
855+
856+
@pytest.mark.parametrize("type_name, choices, values", CHOICES)
857+
def test_multiselect_choices_question_single_answer(
858+
question_tree: QuestionTreeFixture,
859+
copier: CopierFixture,
860+
type_name: QuestionType,
861+
choices: QuestionChoices,
862+
values: ParsedValues,
863+
) -> None:
864+
src, dst = question_tree(type=type_name, choices=choices, multiselect=True)
865+
tui = copier("copy", str(src), str(dst), timeout=10)
866+
expect_prompt(tui, "question", type_name)
867+
tui.send(" ") # select 1
868+
tui.sendline()
869+
tui.expect_exact(pexpect.EOF)
870+
answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
871+
assert answers["question"] == values[:1]
872+
873+
874+
@pytest.mark.parametrize("type_name, choices, values", CHOICES)
875+
def test_multiselect_choices_question_multiple_answers(
876+
question_tree: QuestionTreeFixture,
877+
copier: CopierFixture,
878+
type_name: QuestionType,
879+
choices: QuestionChoices,
880+
values: ParsedValues,
881+
) -> None:
882+
src, dst = question_tree(type=type_name, choices=choices, multiselect=True)
883+
tui = copier("copy", str(src), str(dst), timeout=10)
884+
expect_prompt(tui, "question", type_name)
885+
tui.send(" ") # select 0
886+
tui.send(Keyboard.Down)
887+
tui.send(" ") # select 1
888+
tui.sendline()
889+
tui.expect_exact(pexpect.EOF)
890+
answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
891+
assert answers["question"] == values[:2]
892+
893+
894+
@pytest.mark.parametrize("type_name, choices, values", CHOICES)
895+
def test_multiselect_choices_question_with_default(
896+
question_tree: QuestionTreeFixture,
897+
copier: CopierFixture,
898+
type_name: QuestionType,
899+
choices: QuestionChoices,
900+
values: ParsedValues,
901+
) -> None:
902+
src, dst = question_tree(
903+
type=type_name, choices=choices, multiselect=True, default=values
904+
)
905+
tui = copier("copy", str(src), str(dst), timeout=10)
906+
expect_prompt(tui, "question", type_name)
907+
tui.send(" ") # toggle first
908+
tui.sendline()
909+
tui.expect_exact(pexpect.EOF)
910+
answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
911+
assert answers["question"] == values[1:]
912+
913+
914+
@pytest.mark.parametrize("type_name, choices, values", CHOICES)
915+
def test_update_multiselect_choices(
916+
question_tree: QuestionTreeFixture,
917+
copier: CopierFixture,
918+
type_name: QuestionType,
919+
choices: QuestionChoices,
920+
values: ParsedValues,
921+
) -> None:
922+
"""Multiple choices are properly remembered and selected in TUI when updating."""
923+
src, dst = question_tree(
924+
type=type_name, choices=choices, multiselect=True, default=values
925+
)
926+
927+
with local.cwd(src):
928+
git("init")
929+
git("add", ".")
930+
git("commit", "-m one")
931+
git("tag", "v1")
932+
933+
# Copy
934+
tui = copier("copy", str(src), str(dst), timeout=10)
935+
expect_prompt(tui, "question", type_name)
936+
tui.send(" ") # toggle first
937+
tui.sendline()
938+
tui.expect_exact(pexpect.EOF)
939+
answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
940+
assert answers["question"] == values[1:]
941+
942+
with local.cwd(dst):
943+
git("init")
944+
git("add", ".")
945+
git("commit", "-m1")
946+
947+
# Update
948+
tui = copier("update", str(dst), timeout=10)
949+
expect_prompt(tui, "question", type_name)
950+
tui.send(" ") # toggle first
951+
tui.sendline()
952+
tui.expect_exact(pexpect.EOF)
953+
answers = yaml.safe_load((dst / ".copier-answers.yml").read_text())
954+
assert answers["question"] == values

0 commit comments

Comments
 (0)