Repository: copier-org/copier (python)

Issue:
Using --data with a multiselect based choice
### Describe the problem

Follow on from: https://github.com/orgs/copier-org/discussions/1473



### Template

```
fruit:
    type: str
    help: "Fruit?"
    choices:
        - apple
        - pear
        - banana
    multiselect: true
```

### To Reproduce

```
$ copier copy --data 'fruit=apple' ../copier-issue .
...
ValueError: Invalid choice
```



### Logs

_No response_

### Expected behavior

Primary:
--data is able to find the 'apple' from the 'fruit' multiselect based choice, and select it.

Secondary:
To support multiple options perhaps multiple --data entries are required akin to:

```
--data 'fruit=apple' --data 'fruit=banana'
```

or comma separated

```
--data 'fruit=apple, banana'
```

or an array?

```
--data 'fruit=[apple, banana]'
```


### Screenshots/screencasts/logs

_No response_

### Operating system

macOS

### Operating system distribution and version

Sonoma 14.2.1

### Copier version

copier 9.1.0

### Python version

Python 3.9.6

### Installation method

pip+pypi

### Additional context

_No response_

Pull request description:
feat: add support for providing serialized answers to multiselect choice questions
I've added support for providing serialized answers to multiselect choice questions.

This feature enables two use cases:

1. The default value can be templated by rendering default choice items as a YAML-style list.
2. An answer can be passed via the CLI flag `--data` as a YAML-style list. Resolves #1474.

The answer to a multiselect choice question is a list of choice items. While it seems that the most obvious solution is to parse the answer using a YAML parser, I think it's not the right approach because the list items should be parsed according to `type: <type>` and not necessarily using a YAML parser, and it's inconsistent with other question types to first parse them as YAML and afterwards parse according to `type: <type>`. But I've found a way to shallowly parse the serialized answer as a YAML-style list without parsing the items. This way, the items remain raw strings, delegating their parsing to the parser according to `type: <type>`. There is one subtlety: YAML strings may be quoted or (sometimes) unquoted. To avoid double-quoting after the shallow list parsing, the quotes of a YAML string are stripped.

Patch:
diff --git a/copier/_user_data.py b/copier/_user_data.py
index 57a11b2..a2aad2e 100644
--- a/copier/_user_data.py
+++ b/copier/_user_data.py
@@ -486,6 +486,12 @@ class Question:
     def parse_answer(self, answer: Any) -> Any:
         """Parse the answer according to the question's type."""
         if self.multiselect:
+            # If the answer to a multiselect question is a string (typically because
+            # the default value is templated), then it must be a serialized YAML-style
+            # list of choice items. Thus, we shallowly parse the outermost list first,
+            # then we parse the items according to the `type: <type>` field.
+            if isinstance(answer, str):
+                answer = parse_yaml_list(answer)
             answer = [self._parse_answer(a) for a in answer]
             choices = (
                 self.cast_answer(choice.value) for choice in self._formatted_choices
@@ -523,6 +529,44 @@ def parse_yaml_string(string: str) -> Any:
         raise ValueError(str(error))
 
 
+def parse_yaml_list(string: str) -> list[str]:
+    """Shallowly parse a YAML string that contains a list of items.
+
+    All items remain raw strings, only the outermost list is parsed.
+
+    Args:
+        string: The YAML string.
+
+    Returns:
+        The parsed list of raw items.
+
+    Raises:
+        ValueError: If the YAML string is not a list.
+    """
+    node = yaml.compose(string, Loader=yaml.SafeLoader)
+
+    if not isinstance(node, yaml.nodes.SequenceNode):
+        raise ValueError(f"Not a YAML list: {string!r}")
+
+    items = []
+    for item in node.value:
+        raw = string[item.start_mark.index : item.end_mark.index].strip()
+
+        if (
+            isinstance(item, yaml.nodes.ScalarNode)
+            and item.tag == "tag:yaml.org,2002:str"
+        ):
+            # Strip quotes if the value is quoted to avoid double-quoting.
+            if (raw.startswith('"') and raw.endswith('"')) or (
+                raw.startswith("'") and raw.endswith("'")
+            ):
+                raw = raw[1:-1]
+
+        items.append(raw)
+
+    return items
+
+
 def load_answersfile_data(
     dst_path: StrOrPath,
     answers_file: StrOrPath = ".copier-answers.yml",
diff --git a/docs/configuring.md b/docs/configuring.md
index 303220f..47aa912 100644
--- a/docs/configuring.md
+++ b/docs/configuring.md
@@ -192,6 +192,25 @@ Supported keys:
     choice _value_, not its _key_, and it must match its _type_. If values are quite
     long, you can use
     [YAML anchors](https://confluence.atlassian.com/bitbucket/yaml-anchors-960154027.html).
+
+    !!! note "Dynamic default value of a multiselect choice question"
+
+        The default value of a multiselect choice question can be created dynamically by
+        using a templated string which renders a YAML-style list of choice values. List
+        items are parsed according to the question's `type` and don't need to be quoted
+        unless there is ambiguity with the surrounding list brackets. For example:
+
+        ```diff title="copier.yml"
+         brackets:
+             type: str
+             choices:
+                 - "["
+                 - "]"
+             multiselect: true
+        -    default: '[[, ]]'     # ❌ WRONG
+        +    default: '["[", "]"]' # ✔️ RIGHT
+        ```
+
 -   **secret**: When `true`, it hides the prompt displaying asterisks (`*****`) and
     doesn't save the answer in [the answers file][the-copier-answersyml-file]. When
     `true`, a default value is required.
@@ -795,6 +814,23 @@ questions with default answers.
     copier copy -fd 'user_name=Manuel Calavera' template destination
     ```
 
+!!! example "Give an answer to a multiselect choice question"
+
+    The answer to a multiselect choice question is a YAML-style list of choice values.
+    For example:
+
+    ```shell
+    copier copy -fd 'python_versions=[3.10, 3.11, 3.12]'
+    ```
+
+    List items are parsed according to the question's `type` and don't need to be quoted
+    unless there is ambiguity with the surrounding list brackets. For example:
+
+    ```shell
+    copier copy -fd 'brackets=[[, ]]'     # ❌ WRONG
+    copier copy -fd 'brackets=["[", "]"]' # ✔️ RIGHT
+    ```
+
 ### `data_file`
 
 -   Format: `str`


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 192cb99..75b4460 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -434,3 +434,41 @@ def test_skip_fileexists(template_path: str, tmp_path: Path) -> None:
     assert a_txt.read_text() == "PREVIOUS_CONTENT"
     answers = load_answersfile_data(tmp_path, "altered-answers.yml")
     assert answers["_src_path"] == str(template_path)
+
+
+def test_data_with_multiselect_choice_question(
+    tmp_path_factory: pytest.TempPathFactory,
+) -> None:
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+
+    build_file_tree(
+        {
+            (src / "copier.yml"): (
+                """\
+                q:
+                    type: int
+                    choices: [1, 2, 3]
+                    multiselect: true
+                """
+            ),
+            (src / "{{ _copier_conf.answers_file }}.jinja"): (
+                """\
+                # Changes here will be overwritten by Copier
+                {{ _copier_answers|to_nice_yaml }}
+                """
+            ),
+        }
+    )
+
+    run_result = CopierApp.run(
+        [
+            "copier",
+            "copy",
+            "--data=q=[1, 3]",
+            str(src),
+            str(dst),
+        ],
+        exit=False,
+    )
+    assert run_result[1] == 0
+    assert load_answersfile_data(dst) == {"_src_path": str(src), "q": [1, 3]}
diff --git a/tests/test_copy.py b/tests/test_copy.py
index 144671b..2b0a827 100644
--- a/tests/test_copy.py
+++ b/tests/test_copy.py
@@ -1039,6 +1039,164 @@ def test_templated_choices(tmp_path_factory: pytest.TempPathFactory, spec: str)
     assert yaml.safe_load((dst / "q.txt").read_text()) == "two"
 
 
+@pytest.mark.parametrize(
+    ("spec", "expected"),
+    [
+        (
+            """\
+            type: str
+            choices: ["one", "two", "three"]
+            multiselect: true
+            default: "{{ '[one, three]' }}"
+            """,
+            ["one", "three"],
+        ),
+        (
+            """\
+            type: str
+            choices:
+                one: '[1]'
+                two: '[2]'
+                three: '[3]'
+            multiselect: true
+            # NOTE: Balanced (nested) brackets don't need to be quoted because the
+            # shallow list parser retains raw list items
+            default: "{{ '[[1], [3]]' }}"
+            """,
+            ["[1]", "[3]"],
+        ),
+        (
+            """\
+            type: str
+            choices:
+                left: '['
+                right: ']'
+            multiselect: true
+            # NOTE: Unbalanced brackets need to be explicitly quoted to avoid ambiguity
+            # with the surrounding list brackets.
+            default: >
+                {{ '["[", "]"]' }}
+            """,
+            ["[", "]"],
+        ),
+        (
+            """\
+            type: int
+            choices: [1, 2, 3]
+            multiselect: true
+            default: "{{ '[1, 3]' }}"
+            """,
+            [1, 3],
+        ),
+        (
+            """\
+            type: int
+            choices: ['1', '2', '3']
+            multiselect: true
+            default: >
+                {{ '["1", "3"]' }}
+            """,
+            [1, 3],
+        ),
+        (
+            """\
+            type: float
+            choices: [1.0, 2.0, 3.0]
+            multiselect: true
+            default: "{{ '[1.0, 3.0]' }}"
+            """,
+            [1.0, 3.0],
+        ),
+        (
+            """\
+            type: float
+            choices: ['1.0', '2.0', '3.0']
+            multiselect: true
+            default: >
+                {{ '["1.0", "3.0"]' }}
+            """,
+            [1.0, 3.0],
+        ),
+        (
+            """\
+            type: json
+            choices:
+                one: '{"value": 1}'
+                two: '{"value": 2}'
+                three: '{"value": 3}'
+            multiselect: true
+            default: |
+                {%- if true %}
+                - '{"value": 1}'
+                - '{"value": 3}'
+                {%- endif %}
+            """,
+            [{"value": 1}, {"value": 3}],
+        ),
+        (
+            """\
+            type: yaml
+            choices:
+                one: '{value: [1, null]}'
+                two: '{value: [2, null]}'
+                three: '{value: [3, null]}'
+            multiselect: true
+            default: |
+                {%- if true %}
+                - {value: [1, null]}
+                - {value: [3, null]}
+                {%- endif %}
+            """,
+            [{"value": [1, None]}, {"value": [3, None]}],
+        ),
+        (
+            """\
+            type: yaml
+            choices:
+                one: '{value: [1, null]}'
+                two: '{value: [2, null]}'
+                three: '{value: [3, null]}'
+            multiselect: true
+            default: |
+                {%- if true %}
+                - '{value: [1, null]}'
+                - '{value: [3, null]}'
+                {%- endif %}
+            """,
+            [{"value": [1, None]}, {"value": [3, None]}],
+        ),
+        (
+            """\
+            type: yaml
+            choices:
+                one: '{value: "[1]"}'
+                two: '{value: "[2]"}'
+                three: '{value: "[3]"}'
+            multiselect: true
+            default: |
+                {%- if true %}
+                - {value: '[1]'}
+                - {value: '[3]'}
+                {%- endif %}
+            """,
+            [{"value": "[1]"}, {"value": "[3]"}],
+        ),
+    ],
+)
+def test_multiselect_choices_with_templated_default_value(
+    tmp_path_factory: pytest.TempPathFactory, spec: str, expected: Any
+) -> None:
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+    build_file_tree(
+        {
+            (src / "copier.yml"): f"q:\n{indent(dedent(spec), ' ' * 4)}",
+            (src / "q.yml.jinja"): "{{ q | to_nice_yaml }}",
+        }
+    )
+    copier.run_copy(str(src), dst, defaults=True)
+    assert yaml.safe_load((dst / "q.yml").read_bytes()) == expected
+
+
 def test_copier_phase_variable(tmp_path_factory: pytest.TempPathFactory) -> None:
     src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
     build_file_tree({src / "{{ _copier_phase }}.jinja": "{{ _copier_phase }}"})