copier/tests/test_templated_prompt.py
2025-04-22 13:52:51 +02:00

601 lines
18 KiB
Python

from __future__ import annotations
import json
from collections.abc import Sequence
from datetime import datetime
import pexpect
import pytest
import yaml
from pexpect.popen_spawn import PopenSpawn
from plumbum import local
from copier import Worker
from copier._types import AnyByStrDict
from copier._user_data import load_answersfile_data
from copier.errors import InvalidTypeError
from .helpers import (
BRACKET_ENVOPS,
BRACKET_ENVOPS_JSON,
COPIER_PATH,
SUFFIX_TMPL,
Keyboard,
Spawn,
build_file_tree,
expect_prompt,
git,
git_init,
)
main_default = "copier"
main_question = {
"main": {"default": main_default},
"_envops": BRACKET_ENVOPS,
"_templates_suffix": SUFFIX_TMPL,
}
class Prompt:
def __init__(self, name: str, format: str, help: str | None = None) -> None:
self.name = name
self.format = format
self.help = help
def expect(self, tui: PopenSpawn) -> None:
expect_prompt(tui, self.name, self.format, self.help)
@pytest.mark.parametrize(
"questions_data, expected_value, expected_outputs",
[
(
{"templated_default": {"default": "[[ main ]]default"}},
"copierdefault",
[Prompt("templated_default", "str"), "copierdefault"],
),
(
{
"templated_type": {
"type": "[% if main == 'copier' %]int[% endif %]",
"default": "0",
},
},
0,
[Prompt("templated_type", "int"), "0"],
),
(
{
"templated_help": {
"default": main_default,
"help": "THIS [[ main ]] HELP IS TEMPLATED",
},
},
main_default,
[
Prompt("templated_help", "str", "THIS copier HELP IS TEMPLATED"),
"copier",
],
),
(
{
"templated_choices_dict_1": {
"default": "[[ main ]]",
"choices": {
"choice 1": "[[ main ]]",
"[[ main ]]": "value 2",
},
},
},
main_default,
["(Use arrow keys)", "choice 1", "copier"],
),
(
{
"templated_choices_dict_2": {
"default": "value 2",
"choices": {"choice 1": "[[ main ]]", "[[ main ]]": "value 2"},
},
},
"value 2",
["(Use arrow keys)", "choice 1", "copier"],
),
(
{
"templated_choices_string_list_1": {
"default": main_default,
"choices": ["[[ main ]]", "choice 2"],
},
},
main_default,
["(Use arrow keys)", "copier", "choice 2"],
),
(
{
"templated_choices_string_list_2": {
"default": "choice 1",
"choices": ["choice 1", "[[ main ]]"],
},
},
"choice 1",
["(Use arrow keys)", "choice 1", "copier"],
),
(
{
"templated_choices_tuple_list_1": {
"default": main_default,
"choices": [["name 1", "[[ main ]]"], ["[[ main ]]", "value 2"]],
},
},
main_default,
["(Use arrow keys)", "name 1", "copier"],
),
(
{
"templated_choices_tuple_list_2": {
"default": "value 2",
"choices": [["name 1", "[[ main ]]"], ["[[ main ]]", "value 2"]],
},
},
"value 2",
["name 1", "copier"],
),
(
{
"templated_choices_mixed_list": {
"default": "value 2",
"choices": ["[[ main ]]", ["[[ main ]]", "value 2"]],
},
},
"value 2",
["copier", "copier"],
),
],
)
def test_templated_prompt(
tmp_path_factory: pytest.TempPathFactory,
spawn: Spawn,
questions_data: AnyByStrDict,
expected_value: str | int,
expected_outputs: Sequence[str | Prompt],
) -> None:
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
questions_combined = {**main_question, **questions_data}
# There's always only 1 question; get its name
question_name = next(iter(questions_data))
build_file_tree(
{
(src / "copier.yml"): json.dumps(questions_combined),
(src / "[[ _copier_conf.answers_file ]].tmpl"): (
"[[ _copier_answers|to_nice_yaml ]]"
),
}
)
tui = spawn(COPIER_PATH + ("copy", str(src), str(dst)), timeout=10)
expect_prompt(tui, "main", "str")
tui.expect_exact(main_default)
tui.sendline()
for output in expected_outputs:
if isinstance(output, Prompt):
output.expect(tui)
else:
tui.expect_exact(output)
tui.sendline()
tui.expect_exact(pexpect.EOF)
answers = load_answersfile_data(dst)
assert answers[question_name] == expected_value
def test_templated_prompt_custom_envops(
tmp_path_factory: pytest.TempPathFactory,
) -> None:
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
build_file_tree(
{
(src / "copier.yml"): (
"""\
_envops:
block_start_string: "<%"
block_end_string: "%>"
comment_start_string: "<#"
comment_end_string: "#>"
variable_start_string: "<<"
variable_end_string: ">>"
powerlevel:
type: int
default: 9000
sentence:
type: str
default: "<% if powerlevel >= 9000 %>It's over 9000!<% else %>It's only << powerlevel >>...<% endif %>"
"""
),
(src / "result.jinja"): "<<sentence>>",
}
)
worker1 = Worker(str(src), dst, defaults=True, overwrite=True)
worker1.run_copy()
assert (dst / "result").read_text() == "It's over 9000!"
worker2 = Worker(
str(src), dst, data={"powerlevel": 1}, defaults=True, overwrite=True
)
worker2.run_copy()
assert (dst / "result").read_text() == "It's only 1..."
def test_templated_prompt_builtins(tmp_path_factory: pytest.TempPathFactory) -> None:
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
build_file_tree(
{
(src / "copier.yaml"): (
f"""\
_templates_suffix: {SUFFIX_TMPL}
_envops: {BRACKET_ENVOPS_JSON}
question1:
default: "[[ now() ]]"
question2:
default: "[[ make_secret() ]]"
"""
),
src / "now.tmpl": "[[ question1 ]]",
src / "make_secret.tmpl": "[[ question2 ]]",
}
)
with pytest.warns(FutureWarning) as warnings:
Worker(str(src), dst, defaults=True, overwrite=True).run_copy()
assert len([w for w in warnings if w.category is FutureWarning]) == 2
that_now = datetime.fromisoformat((dst / "now").read_text())
assert that_now <= datetime.utcnow()
assert len((dst / "make_secret").read_text()) == 128
@pytest.mark.parametrize(
"questions, raises, returns",
(
({"question": {"default": "{{ not_valid }}"}}, None, ""),
({"question": {"help": "{{ not_valid }}"}}, None, "None"),
({"question": {"type": "{{ not_valid }}"}}, InvalidTypeError, "None"),
({"question": {"choices": ["{{ not_valid }}"]}}, None, "None"),
),
)
def test_templated_prompt_invalid(
tmp_path_factory: pytest.TempPathFactory,
questions: AnyByStrDict,
raises: type[BaseException] | None,
returns: str,
) -> None:
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
build_file_tree(
{
src / "copier.yml": yaml.safe_dump(questions),
src / "result.jinja": "{{question}}",
}
)
worker = Worker(str(src), dst, data={"question": ""}, overwrite=True)
if raises:
with pytest.raises(raises):
worker.run_copy()
else:
worker.run_copy()
assert (dst / "result").read_text() == returns
@pytest.mark.parametrize(
"cloud, iac_choices",
[
(
"Any",
[
"Terraform",
"Cloud Formation (Requires AWS)",
"Azure Resource Manager (Requires Azure)",
"Deployment Manager (Requires GCP)",
],
),
(
"AWS",
[
"Terraform",
"Cloud Formation",
"Azure Resource Manager (Requires Azure)",
"Deployment Manager (Requires GCP)",
],
),
(
"Azure",
[
"Terraform",
"Cloud Formation (Requires AWS)",
"Azure Resource Manager",
"Deployment Manager (Requires GCP)",
],
),
(
"GCP",
[
"Terraform",
"Cloud Formation (Requires AWS)",
"Azure Resource Manager (Requires Azure)",
"Deployment Manager",
],
),
],
)
def test_templated_prompt_with_conditional_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
Cloud Formation:
value: cf
validator: "{% if cloud != 'AWS' %}Requires AWS{% endif %}"
Azure Resource Manager:
value: arm
validator: "{% if cloud != 'Azure' %}Requires Azure{% endif %}"
Deployment Manager:
value: dm
validator: "{% if cloud != 'GCP' %}Requires GCP{% 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()
@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:
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
Cloud Formation:
value: cf
validator: "{% if cloud != 'AWS' %}Requires AWS{% endif %}"
Azure Resource Manager:
value: arm
validator: "{% if cloud != 'Azure' %}Requires Azure{% endif %}"
Deployment Manager:
value: dm
validator: "{% if cloud != 'GCP' %}Requires GCP{% endif %}"
"""
),
(src / "{{ _copier_conf.answers_file }}.jinja"): (
"{{ _copier_answers|to_nice_yaml }}"
),
}
)
with local.cwd(src):
git_init("v1")
git("tag", "v1")
tui = spawn(COPIER_PATH + ("copy", str(src), str(dst)), timeout=10)
expect_prompt(tui, "cloud", "str", help="Which cloud provider do you use?")
tui.sendline(Keyboard.Down) # select "AWS"
expect_prompt(tui, "iac", "str", help="Which IaC tool do you use?")
tui.sendline(Keyboard.Down) # select "Cloud Formation"
tui.expect_exact(pexpect.EOF)
assert load_answersfile_data(dst) == {
"_src_path": str(src),
"_commit": "v1",
"cloud": "AWS",
"iac": "cf",
}
with local.cwd(dst):
git_init("v1")
tui = spawn(COPIER_PATH + ("update", str(dst)), timeout=10)
expect_prompt(tui, "cloud", "str", help="Which cloud provider do you use?")
tui.sendline(Keyboard.Down) # select "Azure"
expect_prompt(tui, "iac", "str", help="Which IaC tool do you use?")
tui.sendline() # select "Terraform" (first supported)
tui.expect_exact(pexpect.EOF)
assert load_answersfile_data(dst) == {
"_src_path": str(src),
"_commit": "v1",
"cloud": "Azure",
"iac": "tf",
}
def test_multiselect_choices_with_templated_default_value(
tmp_path_factory: pytest.TempPathFactory,
spawn: Spawn,
) -> None:
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
build_file_tree(
{
(src / "copier.yml"): (
"""\
python_version:
type: str
help: What version of python are you targeting?
default: "3.11"
choices:
- "3.8"
- "3.9"
- "3.10"
- "3.11"
- "3.12"
github_runner_python_version:
type: str
help: Which Python versions do you want to use on your Github Runner?
default: ["{{ python_version }}"]
multiselect: true
choices:
- "3.8"
- "3.9"
- "3.10"
- "3.11"
- "3.12"
"""
),
(src / "{{ _copier_conf.answers_file }}.jinja"): (
"{{ _copier_answers|to_nice_yaml }}"
),
}
)
tui = spawn(COPIER_PATH + ("copy", str(src), str(dst)), timeout=10)
expect_prompt(
tui, "python_version", "str", help="What version of python are you targeting?"
)
tui.sendline() # select `3.11" (default value)
expect_prompt(
tui,
"github_runner_python_version",
"str",
help="Which Python versions do you want to use on your Github Runner?",
)
tui.sendline() # select "[3.11]" (default value)
tui.expect_exact(pexpect.EOF)
assert load_answersfile_data(dst) == {
"_src_path": str(src),
"python_version": "3.11",
"github_runner_python_version": ["3.11"],
}
def test_copier_phase_variable(
tmp_path_factory: pytest.TempPathFactory,
spawn: Spawn,
) -> None:
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
build_file_tree(
{
(src / "copier.yml"): """\
phase:
type: str
default: "{{ _copier_phase }}"
"""
}
)
tui = spawn(COPIER_PATH + ("copy", str(src), str(dst)), timeout=10)
expect_prompt(tui, "phase", "str")
tui.expect_exact("prompt")
def test_copier_conf_variable(
tmp_path_factory: pytest.TempPathFactory,
spawn: Spawn,
) -> None:
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
build_file_tree(
{
(src / "copier.yml"): """\
project_name:
type: str
default: "{{ _copier_conf.dst_path | basename }}"
"""
}
)
tui = spawn(COPIER_PATH + ("copy", str(src), str(dst / "test_project")), timeout=10)
expect_prompt(tui, "project_name", "str")
tui.expect_exact("test_project")