Skip to content

Commit

Permalink
feat(choices): support questionary checkbox for multiple choices usin…
Browse files Browse the repository at this point in the history
…g `multiselect: true`.

Fixes #218
  • Loading branch information
noirbizarre committed Oct 30, 2023
1 parent 9ea980f commit 7f97628
Show file tree
Hide file tree
Showing 3 changed files with 198 additions and 6 deletions.
33 changes: 28 additions & 5 deletions copier/user_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions docs/configuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
169 changes: 168 additions & 1 deletion tests/test_prompt.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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"""\
Expand Down Expand Up @@ -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]:
...

Check warning on line 815 in tests/test_prompt.py

View check run for this annotation

Codecov / codecov/patch

tests/test_prompt.py#L815

Added line #L815 was not covered by tests


@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:
...

Check warning on line 843 in tests/test_prompt.py

View check run for this annotation

Codecov / codecov/patch

tests/test_prompt.py#L843

Added line #L843 was not covered by tests


@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

0 comments on commit 7f97628

Please sign in to comment.