Skip to content

Commit

Permalink
feat: add support for dynamic choices
Browse files Browse the repository at this point in the history
  • Loading branch information
sisp committed Oct 8, 2024
1 parent 5e57bbf commit e0e2275
Show file tree
Hide file tree
Showing 4 changed files with 176 additions and 3 deletions.
8 changes: 5 additions & 3 deletions copier/user_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ class Question:
var_name: str
answers: AnswersMap
jinja_env: SandboxedEnvironment
choices: Sequence[Any] | dict[Any, Any] = field(default_factory=list)
choices: Sequence[Any] | dict[Any, Any] | str = field(default_factory=list)
multiselect: bool = False
default: Any = MISSING
help: str = ""
Expand Down Expand Up @@ -291,8 +291,10 @@ def _formatted_choices(self) -> Sequence[Choice]:
result = []
choices = self.choices
default = self.get_default()
if isinstance(self.choices, dict):
choices = list(self.choices.items())
if isinstance(choices, str):
choices = parse_yaml_string(self.render_value(self.choices))
if isinstance(choices, dict):
choices = list(choices.items())
for choice in choices:
# If a choice is a value pair
if isinstance(choice, (tuple, list)):
Expand Down
35 changes: 35 additions & 0 deletions docs/configuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,41 @@ Supported keys:
the message is shown. Choice validation is useful when the validity of a choice
depends on the answer to a previous question.

!!! tip "Dynamic choices"

Choices can be created dynamically by using a templated string which renders
as valid list-style, dict-style, or tuple-style choices in YAML format. For
example:

```yaml title="copier.yml"
language:
type: str
help: Which programming language do you use?
choices:
- python
- node

dependency_manager:
type: str
help: Which dependency manager do you use?
choices: |
{%- if language == "python" %}
- poetry
- pipenv
{%- else %}
- npm
- yarn
{%- endif %}
```

Dynamic choices can be used as an alternative approach to conditional choices
via validators where dynamic choices hide disabled choices whereas choices
disabled via validators are visible with along with the validator's error
message but cannot be selected.

When combining dynamic choices with validators, make sure to escape the
validator template using `{% raw %}...{% endraw %}`.

!!! warning

You are able to use different types for each choice value, but it is not
Expand Down
79 changes: 79 additions & 0 deletions tests/test_copy.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from decimal import Decimal
from enum import Enum
from pathlib import Path
from textwrap import dedent, indent
from time import sleep
from typing import Any, ContextManager

Expand Down Expand Up @@ -963,3 +964,81 @@ def test_multiselect_choices_preserve_order(
)
copier.run_copy(str(src), dst, data={"q": ["three", "one", "two"]})
assert yaml.safe_load((dst / "q.yml").read_text()) == ["one", "two", "three"]


@pytest.mark.parametrize(
"spec",
[
(
"""\
choices: '["one", "two", "three"]'
"""
),
(
"""\
choices: '[{% for c in ["one", "two", "three"] %}{{ c }}{% if not loop.last %},{% endif %}{% endfor %}]'
"""
),
(
"""\
choices: |
{%- for c in ['one', 'two', 'three'] %}
- {{ c }}
{%- endfor %}
"""
),
(
"""\
choices: |
{%- for c in ['one', 'two', 'three'] %}
{{ c }}: null
{%- endfor %}
"""
),
(
"""\
choices: |
{%- for c in ['one', 'two', 'three'] %}
{{ c|upper }}: {{ c }}
{%- endfor %}
"""
),
(
"""\
choices: |
{%- for c in ['one', 'two', 'three'] %}
- [{{ c|upper }}, {{ c }}]
{%- endfor %}
"""
),
(
"""\
choices: |
{%- for c in ['one', 'two', 'three'] %}
{{ c|upper }}:
value: {{ c }}
{%- endfor %}
"""
),
(
"""\
choices: |
{%- for c in ['one', 'two', 'three'] %}
{{ c|upper }}:
value: {{ c }}
validator: "{% raw %}{% if false %}Validation error{% endif %}{% endraw %}"
{%- endfor %}
"""
),
],
)
def test_templated_choices(tmp_path_factory: pytest.TempPathFactory, spec: str) -> None:
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
build_file_tree(
{
(src / "copier.yml"): f"q:\n type: str\n{indent(dedent(spec), ' ' * 4)}",
(src / "q.txt.jinja"): "{{ q }}",
}
)
copier.run_copy(str(src), dst, data={"q": "two"})
assert yaml.safe_load((dst / "q.txt").read_text()) == "two"
57 changes: 57 additions & 0 deletions tests/test_templated_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,63 @@ def test_templated_prompt_with_conditional_choices(
tui.sendline()


@pytest.mark.parametrize(
"cloud, iac_choices",
[
("Any", ["Terraform"]),
("AWS", ["Terraform", "Cloud Formation"]),
("Azure", ["Terraform", "Azure Resource Manager"]),
("GCP", ["Terraform", "Deployment Manager"]),
],
)
def test_templated_prompt_with_templated_choices(
tmp_path_factory: pytest.TempPathFactory,
spawn: Spawn,
cloud: str,
iac_choices: str,
) -> None:
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
build_file_tree(
{
(src / "copier.yml"): (
"""\
cloud:
type: str
help: Which cloud provider do you use?
choices:
- Any
- AWS
- Azure
- GCP
iac:
type: str
help: Which IaC tool do you use?
choices: |
Terraform: tf
{%- if cloud == 'AWS' %}
Cloud Formation: cf
{%- endif %}
{%- if cloud == 'Azure' %}
Azure Resource Manager: arm
{%- endif %}
{%- if cloud == 'GCP' %}
Deployment Manager: dm
{%- endif %}
"""
),
}
)
tui = spawn(
COPIER_PATH + ("copy", f"--data=cloud={cloud}", str(src), str(dst)),
timeout=10,
)
expect_prompt(tui, "iac", "str", help="Which IaC tool do you use?")
for iac in iac_choices:
tui.expect_exact(iac)
tui.sendline()


def test_templated_prompt_update_previous_answer_disabled(
tmp_path_factory: pytest.TempPathFactory, spawn: Spawn
) -> None:
Expand Down

0 comments on commit e0e2275

Please sign in to comment.