Repository: copier-org/copier
Pull request #1821 (2024-10-16 18:54:25)

Introducing Copier Guru on Gurubase.io
Hello Copier team,

I'm the maintainer of [Anteon](https://github.com/getanteon/anteon). We have created Gurubase.io with the mission of building a centralized, open-source tool-focused knowledge base. Essentially, each "guru" is equipped with custom knowledge to answer user questions based on collected data related to that tool.

I wanted to update you that I've manually added the [Copier Guru](https://gurubase.io/g/copier) to Gurubase. Copier Guru uses the data from this repo and data from the [docs](https://copier.readthedocs.io/en/stable/) to answer questions by leveraging the LLM.

In this PR, I showcased the "Copier Guru" badge, which highlights that Copier now has an AI assistant available to help users with their questions. Please let me know your thoughts on this contribution.

Additionally, if you want me to disable Copier Guru in Gurubase, just let me know that's totally fine.


pawamoy: Interesting, thanks for the heads up. I asked a few questions. The results ranged from good to not bad but incomplete :slightly_smiling_face: 
kursataktas: We have an exciting release coming up in a few days that will significantly improve answer accuracy. I would love to hear your thoughts after it's out—would that work for you?


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e097b52..b5d795d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. This projec
 adheres to [PEP 440](https://www.python.org/dev/peps/pep-0440/) versioning schema, and
 the changelog itself conforms to [Keep A Changelog](https://keepachangelog.com/).
 
+## v9.4.1 (2024-10-18)
+
+### Fix
+
+-   restore support for `preserve_symlinks: false` for directories (#1820)
+
 ## v9.4.0 (2024-10-15)
 
 ### Fix
diff --git a/README.md b/README.md
index d81f3c4..9886b09 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@
 [![PyPI](https://img.shields.io/pypi/v/copier?logo=pypi&logoColor=%23959DA5)](https://pypi.org/project/copier/)
 [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
 [![Documentation Status](https://img.shields.io/readthedocs/copier/latest?logo=readthedocs)](https://copier.readthedocs.io/en/latest)
+[![](https://img.shields.io/badge/Gurubase-Ask%20Copier%20Guru-006BFF)](https://gurubase.io/g/copier)
 
 A library and CLI app for rendering project templates.
 
diff --git a/copier/user_data.py b/copier/user_data.py
index 0a2af77..85698fa 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 @@ class Question:
         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 13c2d29..636f04e 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/pyproject.toml b/pyproject.toml
index 2fb505f..601fcec 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -168,7 +168,7 @@ annotated_tag = true
 changelog_incremental = true
 tag_format = "v$version"
 update_changelog_on_bump = true
-version = "9.4.0"
+version = "9.4.1"
 
 [build-system]
 requires = ["poetry-core>=1.7.0", "poetry-dynamic-versioning>=1.1.0"]


Test patch:
diff --git a/tests/test_copy.py b/tests/test_copy.py
index 0717a61..c98060a 100644
--- a/tests/test_copy.py
+++ b/tests/test_copy.py
@@ -9,6 +9,7 @@ from contextlib import nullcontext as does_not_raise
 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 c69c49c..d7e745d 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: