diff --git a/copier/user_data.py b/copier/user_data.py index 6c481a50f..1dea4926d 100644 --- a/copier/user_data.py +++ b/copier/user_data.py @@ -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 = "" @@ -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)): diff --git a/docs/configuring.md b/docs/configuring.md index 13c2d297b..636f04ea3 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -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 diff --git a/tests/test_copy.py b/tests/test_copy.py index 0717a61d7..c98060a1a 100644 --- a/tests/test_copy.py +++ b/tests/test_copy.py @@ -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 @@ -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" diff --git a/tests/test_templated_prompt.py b/tests/test_templated_prompt.py index c69c49c0a..d7e745dc0 100644 --- a/tests/test_templated_prompt.py +++ b/tests/test_templated_prompt.py @@ -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: