From 7f976283ea4af0ad8f6ddfa009cdbeeb801f7fbb Mon Sep 17 00:00:00 2001 From: "Axel H." Date: Mon, 16 Oct 2023 18:34:08 +0200 Subject: [PATCH] feat(choices): support questionary checkbox for multiple choices using `multiselect: true`. Fixes #218 --- copier/user_data.py | 33 +++++++-- docs/configuring.md | 2 + tests/test_prompt.py | 169 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 198 insertions(+), 6 deletions(-) diff --git a/copier/user_data.py b/copier/user_data.py index f1ccb1ece..3422dae3b 100644 --- a/copier/user_data.py +++ b/copier/user_data.py @@ -127,6 +127,10 @@ class Question: Selections available for the user if the question requires them. Can be templated. + multiselect: + Indicates if the question supports multiple answers. + Only supported by choices type. + default: Default value presented to the user to make it easier to respond. Can be templated. @@ -173,6 +177,7 @@ class Question: answers: AnswersMap jinja_env: SandboxedEnvironment choices: Union[Sequence[Any], Dict[Any, Any]] = field(default_factory=list) + multiselect: bool = False default: Any = MISSING help: str = "" multiline: Union[str, bool] = False @@ -215,6 +220,8 @@ def cast_answer(self, answer: Any) -> Any: f'to question "{self.var_name}" of type "{type_name}"' ) try: + if self.multiselect and isinstance(answer, list): + return [type_fn(item) for item in answer] return type_fn(answer) except (TypeError, AttributeError) as error: # 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]: return MISSING # If there are choices, return the one that matches the expressed default if self.choices: - for choice in self._formatted_choices: - if choice.value == default: - return choice + # questionary checkbox use Choice.checked for multiple default + if not self.multiselect: + for choice in self._formatted_choices: + if choice.value == default: + return choice return None # Yes/No questions expect and return bools if isinstance(default, bool) and self.get_type_name() == "bool": @@ -278,6 +287,7 @@ def _formatted_choices(self) -> Sequence[Choice]: """Obtain choices rendered and properly formatted.""" result = [] choices = self.choices + default = self.get_default() if isinstance(self.choices, dict): choices = list(self.choices.items()) for choice in choices: @@ -297,11 +307,18 @@ def _formatted_choices(self) -> Sequence[Choice]: raise KeyError("Property 'value' is required") if "validator" in value and not isinstance(value["validator"], str): raise ValueError("Property 'validator' must be a string") + disabled = self.render_value(value.get("validator", "")) value = value["value"] # The value can be templated value = self.render_value(value) - c = Choice(name, value, disabled=disabled) + checked = ( + self.multiselect + and isinstance(default, list) + and self.cast_answer(value) in default + or None + ) + c = Choice(name, value, disabled=disabled, checked=checked) # Try to cast the value according to the question's type to raise # an error in case the value is incompatible. self.cast_answer(c.value) @@ -347,7 +364,7 @@ def get_questionary_structure(self) -> AnyByStrDict: if default is MISSING: result["default"] = False if self.choices: - questionary_type = "select" + questionary_type = "checkbox" if self.multiselect else "select" result["choices"] = self._formatted_choices if questionary_type == "input": if self.secret: @@ -419,6 +436,12 @@ def render_value( def parse_answer(self, answer: Any) -> Any: """Parse the answer according to the question's type.""" + if self.multiselect: + return [self._parse_answer(a) for a in answer] + return self._parse_answer(answer) + + def _parse_answer(self, answer: Any) -> Any: + """Parse a single answer according to the question's type.""" ans = self.cast_answer(answer) choices = self._formatted_choices if not choices: diff --git a/docs/configuring.md b/docs/configuring.md index 5e28894d3..b1bc840fb 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -150,6 +150,8 @@ Supported keys: Some array: "[str, keeps, this, as, a, str]" ``` +- **multiselect**: When set to `true`, allows multiple choices. The answer will be a + `list[T]` instead of a `T` where `T` is of type `type`. - **default**: Leave empty to force the user to answer. Provide a default to save them from typing it if it's quite common. When using `choices`, the default must be the choice _value_, not its _key_, and it must match its _type_. If values are quite diff --git a/tests/test_prompt.py b/tests/test_prompt.py index 4daf47989..bcc6ba28f 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -1,9 +1,10 @@ from pathlib import Path -from typing import Any, Dict, List, Mapping, Tuple, Union +from typing import Any, Dict, List, Mapping, Protocol, Tuple, Union import pexpect import pytest import yaml +from pexpect.popen_spawn import PopenSpawn from plumbum import local from plumbum.cmd import git @@ -21,6 +22,12 @@ git_save, ) +try: + from typing import TypeAlias # type: ignore[attr-defined] +except ImportError: + from typing_extensions import TypeAlias + + MARIO_TREE: Mapping[StrOrPath, Union[str, bytes]] = { "copier.yml": ( f"""\ @@ -785,3 +792,163 @@ def test_required_choice_question( "_src_path": str(src), "question": expected_answer, } + + +QuestionType: TypeAlias = str +QuestionChoices: TypeAlias = Union[List[Any], Dict[str, Any]] +ParsedValues: TypeAlias = List[Any] + +_CHOICES: Dict[str, Tuple[QuestionType, QuestionChoices, ParsedValues]] = { + "str": ("str", ["one", "two", "three"], ["one", "two", "three"]), + "int": ("int", [1, 2, 3], [1, 2, 3]), + "int-label-list": ("int", [["one", 1], ["two", 2], ["three", 3]], [1, 2, 3]), + "int-label-dict": ("int", {"1. one": 1, "2. two": 2, "3. three": 3}, [1, 2, 3]), + "float": ("float", [1.0, 2.0, 3.0], [1.0, 2.0, 3.0]), + "json": ("json", ["[1]", "[2]", "[3]"], [[1], [2], [3]]), + "yaml": ("yaml", ["- 1", "- 2", "- 3"], [[1], [2], [3]]), +} +CHOICES = [pytest.param(*specs, id=id) for id, specs in _CHOICES.items()] + + +class QuestionTreeFixture(Protocol): + def __call__(self, **kwargs) -> Tuple[Path, Path]: + ... + + +@pytest.fixture +def question_tree(tmp_path_factory: pytest.TempPathFactory) -> QuestionTreeFixture: + def builder(**question) -> Tuple[Path, Path]: + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + (src / "copier.yml"): yaml.dump( + { + "_envops": BRACKET_ENVOPS, + "_templates_suffix": SUFFIX_TMPL, + "question": question, + } + ), + (src / "[[ _copier_conf.answers_file ]].tmpl"): ( + "[[ _copier_answers|to_nice_yaml ]]" + ), + } + ) + return src, dst + + return builder + + +class CopierFixture(Protocol): + def __call__(self, *args, **kwargs) -> PopenSpawn: + ... + + +@pytest.fixture +def copier(spawn: Spawn) -> CopierFixture: + """Multiple choices are properly remembered and selected in TUI when updating.""" + + def fixture(*args, **kwargs) -> PopenSpawn: + return spawn(COPIER_PATH + args, **kwargs) + + return fixture + + +@pytest.mark.parametrize("type_name, choices, values", CHOICES) +def test_multiselect_choices_question_single_answer( + question_tree: QuestionTreeFixture, + copier: CopierFixture, + type_name: QuestionType, + choices: QuestionChoices, + values: ParsedValues, +) -> None: + src, dst = question_tree(type=type_name, choices=choices, multiselect=True) + tui = copier("copy", str(src), str(dst), timeout=10) + expect_prompt(tui, "question", type_name) + tui.send(" ") # select 1 + tui.sendline() + tui.expect_exact(pexpect.EOF) + answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) + assert answers["question"] == values[:1] + + +@pytest.mark.parametrize("type_name, choices, values", CHOICES) +def test_multiselect_choices_question_multiple_answers( + question_tree: QuestionTreeFixture, + copier: CopierFixture, + type_name: QuestionType, + choices: QuestionChoices, + values: ParsedValues, +) -> None: + src, dst = question_tree(type=type_name, choices=choices, multiselect=True) + tui = copier("copy", str(src), str(dst), timeout=10) + expect_prompt(tui, "question", type_name) + tui.send(" ") # select 0 + tui.send(Keyboard.Down) + tui.send(" ") # select 1 + tui.sendline() + tui.expect_exact(pexpect.EOF) + answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) + assert answers["question"] == values[:2] + + +@pytest.mark.parametrize("type_name, choices, values", CHOICES) +def test_multiselect_choices_question_with_default( + question_tree: QuestionTreeFixture, + copier: CopierFixture, + type_name: QuestionType, + choices: QuestionChoices, + values: ParsedValues, +) -> None: + src, dst = question_tree( + type=type_name, choices=choices, multiselect=True, default=values + ) + tui = copier("copy", str(src), str(dst), timeout=10) + expect_prompt(tui, "question", type_name) + tui.send(" ") # toggle first + tui.sendline() + tui.expect_exact(pexpect.EOF) + answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) + assert answers["question"] == values[1:] + + +@pytest.mark.parametrize("type_name, choices, values", CHOICES) +def test_update_multiselect_choices( + question_tree: QuestionTreeFixture, + copier: CopierFixture, + type_name: QuestionType, + choices: QuestionChoices, + values: ParsedValues, +) -> None: + """Multiple choices are properly remembered and selected in TUI when updating.""" + src, dst = question_tree( + type=type_name, choices=choices, multiselect=True, default=values + ) + + with local.cwd(src): + git("init") + git("add", ".") + git("commit", "-m one") + git("tag", "v1") + + # Copy + tui = copier("copy", str(src), str(dst), timeout=10) + expect_prompt(tui, "question", type_name) + tui.send(" ") # toggle first + tui.sendline() + tui.expect_exact(pexpect.EOF) + answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) + assert answers["question"] == values[1:] + + with local.cwd(dst): + git("init") + git("add", ".") + git("commit", "-m1") + + # Update + tui = copier("update", str(dst), timeout=10) + expect_prompt(tui, "question", type_name) + tui.send(" ") # toggle first + tui.sendline() + tui.expect_exact(pexpect.EOF) + answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) + assert answers["question"] == values