from __future__ import annotations import json from collections import deque from pathlib import Path from textwrap import dedent import pexpect import pytest import yaml from pexpect.popen_spawn import PopenSpawn from plumbum import local from copier import run_copy, run_update from copier._user_data import load_answersfile_data from .helpers import ( BRACKET_ENVOPS, BRACKET_ENVOPS_JSON, COPIER_PATH, SUFFIX_TMPL, Keyboard, Spawn, build_file_tree, expect_prompt, git, ) BLK_START = BRACKET_ENVOPS["block_start_string"] BLK_END = BRACKET_ENVOPS["block_end_string"] @pytest.fixture(scope="module") def template_path(tmp_path_factory: pytest.TempPathFactory) -> str: root = tmp_path_factory.mktemp("template") build_file_tree( { (root / "copier.yml"): ( f"""\ _templates_suffix: {SUFFIX_TMPL} _envops: {BRACKET_ENVOPS_JSON} love_me: help: I need to know it. Do you love me? type: bool your_name: help: Please tell me your name. type: str your_age: help: How old are you? type: int validator: |- [% if your_age <= 0 %] Must be + [% endif %] your_height: help: What's your height? type: float more_json_info: multiline: true type: json anything_else: multiline: true help: Wanna give me any more info? # In choices, the user will always write the choice index (1, 2, 3...) choose_list: help: You see the value of the list items default: first choices: - first - second - third choose_tuple: help: You see the 1st tuple item, but I get the 2nd item as a result default: second choices: - [one, first] - [two, second] - [three, third] choose_dict: help: You see the dict key, but I get the dict value default: third choices: one: first two: second three: third choose_number: help: This must be a number default: -1.1 type: float choices: - -1.1 - 0 - 1 # Simplified format is still supported minutes_under_water: 10 optional_value: null """ ), (root / "results.txt.tmpl"): ( """\ love_me: [[love_me|tojson]] your_name: [[your_name|tojson]] your_age: [[your_age|tojson]] your_height: [[your_height|tojson]] more_json_info: [[more_json_info|tojson]] anything_else: [[anything_else|tojson]] choose_list: [[choose_list|tojson]] choose_tuple: [[choose_tuple|tojson]] choose_dict: [[choose_dict|tojson]] choose_number: [[choose_number|tojson]] minutes_under_water: [[minutes_under_water|tojson]] optional_value: [[optional_value|tojson]] """ ), } ) return str(root) def check_invalid( tui: PopenSpawn, name: str, format: str, invalid_value: str, help: str | None = None, err: str = "Invalid input", ) -> None: """Check that invalid input is reported correctly""" expect_prompt(tui, name, format, help) tui.sendline(invalid_value) tui.expect_exact(invalid_value) tui.expect_exact(err) def test_api(template_path: str, tmp_path: Path) -> None: """Test copier correctly processes advanced questions and answers through API.""" run_copy( template_path, tmp_path, { "love_me": False, "your_name": "LeChuck", "your_age": 220, "your_height": 1.9, "more_json_info": ["bad", "guy"], "anything_else": {"hates": "all"}, }, defaults=True, overwrite=True, ) assert (tmp_path / "results.txt").read_text() == dedent( """\ love_me: false your_name: "LeChuck" your_age: 220 your_height: 1.9 more_json_info: ["bad", "guy"] anything_else: {"hates": "all"} choose_list: "first" choose_tuple: "second" choose_dict: "third" choose_number: -1.1 minutes_under_water: 10 optional_value: null """ ) def test_cli_interactive(template_path: str, tmp_path: Path, spawn: Spawn) -> None: """Test copier correctly processes advanced questions and answers through CLI.""" tui = spawn(COPIER_PATH + ("copy", template_path, str(tmp_path)), timeout=10) expect_prompt(tui, "love_me", "bool", help="I need to know it. Do you love me?") tui.send("y") expect_prompt(tui, "your_name", "str", help="Please tell me your name.") tui.sendline("Guybrush Threpwood") check_invalid(tui, "your_age", "int", "wrong your_age", help="How old are you?") tui.send((Keyboard.Alt + Keyboard.Backspace) * 2) tui.send("-1") tui.expect_exact("Must be +") tui.send(Keyboard.Backspace * 2) tui.sendline("22") check_invalid( tui, "your_height", "float", "wrong your_height", help="What's your height?" ) tui.send((Keyboard.Alt + Keyboard.Backspace) * 2) tui.sendline("1.56") check_invalid(tui, "more_json_info", "json", '{"objective":') tui.sendline('"be a pirate"}') tui.send(Keyboard.Esc + Keyboard.Enter) expect_prompt(tui, "anything_else", "yaml", help="Wanna give me any more info?") tui.sendline("- Want some grog?") tui.sendline("- I'd love it") tui.send(Keyboard.Esc + Keyboard.Enter) expect_prompt(tui, "choose_list", "str", help="You see the value of the list items") deque( map( tui.expect_exact, [ "first", "second", "third", ], ) ) tui.sendline() expect_prompt( tui, "choose_tuple", "str", help="You see the 1st tuple item, but I get the 2nd item as a result", ) deque( map( tui.expect_exact, [ "one", "two", "three", ], ) ) tui.sendline() expect_prompt( tui, "choose_dict", "str", help="You see the dict key, but I get the dict value" ) deque( map( tui.expect_exact, [ "one", "two", "three", ], ) ) tui.sendline() expect_prompt(tui, "choose_number", "float", help="This must be a number") deque( map( tui.expect_exact, ["-1.1", "0", "1"], ) ) tui.sendline() expect_prompt(tui, "minutes_under_water", "int") tui.expect_exact("10") tui.send(Keyboard.Backspace) tui.sendline() expect_prompt(tui, "optional_value", "yaml") tui.sendline() tui.expect_exact(pexpect.EOF) assert (tmp_path / "results.txt").read_text() == dedent( """\ love_me: true your_name: "Guybrush Threpwood" your_age: 22 your_height: 1.56 more_json_info: {"objective": "be a pirate"} anything_else: ["Want some grog?", "I\\u0027d love it"] choose_list: "first" choose_tuple: "second" choose_dict: "third" choose_number: -1.1 minutes_under_water: 1 optional_value: null """ ) def test_api_str_data(template_path: str, tmp_path: Path) -> None: """Test copier when all data comes as a string. This happens i.e. when using the --data CLI argument. """ run_copy( template_path, tmp_path, data={ "love_me": "false", "your_name": "LeChuck", "your_age": "220", "your_height": "1.9", "more_json_info": '["bad", "guy"]', "anything_else": "{'hates': 'all'}", "choose_number": "0", }, defaults=True, overwrite=True, ) assert (tmp_path / "results.txt").read_text() == dedent( """\ love_me: false your_name: "LeChuck" your_age: 220 your_height: 1.9 more_json_info: ["bad", "guy"] anything_else: {"hates": "all"} choose_list: "first" choose_tuple: "second" choose_dict: "third" choose_number: 0.0 minutes_under_water: 10 optional_value: null """ ) def test_cli_interatively_with_flag_data_and_type_casts( template_path: str, tmp_path: Path, spawn: Spawn ) -> None: """Assert how choices work when copier is invoked with --data interactively.""" tui = spawn( COPIER_PATH + ( "copy", "--data=choose_list=second", "--data=choose_dict=first", "--data=choose_tuple=third", "--data=choose_number=1", template_path, str(tmp_path), ), timeout=10, ) expect_prompt(tui, "love_me", "bool", help="I need to know it. Do you love me?") tui.send("y") expect_prompt(tui, "your_name", "str", help="Please tell me your name.") tui.sendline("Guybrush Threpwood") check_invalid(tui, "your_age", "int", "wrong your_age", help="How old are you?") tui.sendline() # Does nothing, "wrong your_age still on screen" tui.send((Keyboard.Alt + Keyboard.Backspace) * 2) tui.sendline("22") check_invalid( tui, "your_height", "float", "wrong your_height", help="What's your height?" ) tui.send((Keyboard.Alt + Keyboard.Backspace) * 2) tui.sendline("1.56") check_invalid(tui, "more_json_info", "json", '{"objective":') tui.sendline('"be a pirate"}') tui.send(Keyboard.Esc + Keyboard.Enter) expect_prompt(tui, "anything_else", "yaml", help="Wanna give me any more info?") tui.sendline("- Want some grog?") tui.sendline("- I'd love it") tui.send(Keyboard.Esc + Keyboard.Enter) expect_prompt(tui, "minutes_under_water", "int") tui.expect_exact("10") tui.sendline() expect_prompt(tui, "optional_value", "yaml") tui.sendline() tui.expect_exact(pexpect.EOF) results_file = tmp_path / "results.txt" assert results_file.read_text() == dedent( """\ love_me: true your_name: "Guybrush Threpwood" your_age: 22 your_height: 1.56 more_json_info: {"objective": "be a pirate"} anything_else: ["Want some grog?", "I\\u0027d love it"] choose_list: "second" choose_tuple: "third" choose_dict: "first" choose_number: 1.0 minutes_under_water: 10 optional_value: null """ ) @pytest.mark.parametrize( "has_2_owners, owner2", [(True, "example2"), (False, "example")] ) def test_tui_inherited_default( tmp_path_factory: pytest.TempPathFactory, spawn: Spawn, has_2_owners: bool, owner2: str, ) -> None: """Make sure a template inherits default as expected.""" src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) build_file_tree( { (src / "copier.yaml"): ( """\ owner1: type: str has_2_owners: type: bool default: false owner2: type: str default: "{{ owner1 }}" when: "{{ has_2_owners }}" """ ), (src / "{{ _copier_conf.answers_file }}.jinja"): ( "{{ _copier_answers|to_nice_yaml }}" ), (src / "answers.json.jinja"): "{{ _copier_answers|to_nice_json }}", (src / "context.json.jinja"): '{"owner2": "{{ owner2 }}"}', } ) with local.cwd(src): git("init") git("add", "--all") git("commit", "--message", "init template") git("tag", "1") tui = spawn(COPIER_PATH + ("copy", str(src), str(dst)), timeout=10) expect_prompt(tui, "owner1", "str") tui.sendline("example") expect_prompt(tui, "has_2_owners", "bool") tui.expect_exact("(y/N)") tui.send("y" if has_2_owners else "n") if has_2_owners: expect_prompt(tui, "owner2", "str") tui.expect_exact("example") tui.sendline("2") tui.expect_exact(pexpect.EOF) result = { "_commit": "1", "_src_path": str(src), "has_2_owners": has_2_owners, "owner1": "example", **({"owner2": owner2} if has_2_owners else {}), } assert json.loads((dst / "answers.json").read_text()) == result assert json.loads((dst / "context.json").read_text()) == {"owner2": owner2} with local.cwd(dst): git("init") git("add", "--all") git("commit", "--message", "init project") # After a forced update, answers stay the same run_update(dst, defaults=True, overwrite=True) assert json.loads((dst / "answers.json").read_text()) == result def test_tui_typed_default( tmp_path_factory: pytest.TempPathFactory, spawn: Spawn ) -> None: """Make sure a template defaults are typed as expected.""" src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) build_file_tree( { (src / "copier.yaml"): ( """\ test1: type: bool default: false when: false test2: type: bool default: "{{ 'a' == 'b' }}" when: false """ ), (src / "{{ _copier_conf.answers_file }}.jinja"): ( "{{ _copier_answers|to_nice_yaml }}" ), (src / "answers.json.jinja"): "{{ _copier_answers|to_nice_json }}", (src / "context.json.jinja"): ( """\ {"test1": "{{ test1 }}", "test2": "{{ test2 }}"} """ ), } ) tui = spawn(COPIER_PATH + ("copy", str(src), str(dst)), timeout=10) tui.expect_exact(pexpect.EOF) assert json.loads((dst / "answers.json").read_text()) == {"_src_path": str(src)} assert json.loads((dst / "context.json").read_text()) == { "test1": "False", "test2": "False", } def test_selection_type_cast( tmp_path_factory: pytest.TempPathFactory, spawn: Spawn ) -> None: """Selection question with different types, properly casted.""" src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) build_file_tree( { (src / "copier.yaml"): ( """\ postgres1: &pg type: yaml default: 10 help: Which PostgreSQL version do you want to deploy? choices: I will use an external PostgreSQL server: null "9.6": 9.6 "10": 10 postgres2: *pg postgres3: *pg nulls1: &nulls type: yaml choices: - [same as key, null] - [yaml-casted to a real null, "null"] - [becomes array, "[one, 2, -3.0]"] nulls2: *nulls nulls3: *nulls """ ), (src / "answers.json.jinja"): "{{ _copier_answers|to_json }}", } ) tui = spawn(COPIER_PATH + ("copy", str(src), str(dst)), timeout=10) expect_prompt( tui, "postgres1", "yaml", help="Which PostgreSQL version do you want to deploy?" ) tui.sendline(Keyboard.Down) expect_prompt( tui, "postgres2", "yaml", help="Which PostgreSQL version do you want to deploy?" ) tui.sendline(Keyboard.Up) expect_prompt( tui, "postgres3", "yaml", help="Which PostgreSQL version do you want to deploy?" ) tui.sendline() expect_prompt(tui, "nulls1", "yaml") tui.sendline() expect_prompt(tui, "nulls2", "yaml") tui.sendline(Keyboard.Down) expect_prompt(tui, "nulls3", "yaml") tui.sendline(Keyboard.Down + Keyboard.Down) tui.expect_exact(pexpect.EOF) assert json.loads((dst / "answers.json").read_bytes()) == { "_src_path": str(src), "nulls1": "same as key", "nulls2": None, "nulls3": ["one", 2, -3.0], "postgres1": "I will use an external PostgreSQL server", "postgres2": 9.6, "postgres3": 10, } def test_multi_template_answers(tmp_path_factory: pytest.TempPathFactory) -> None: tpl1, tpl2, dst = map(tmp_path_factory.mktemp, map(str, range(3))) # Build 2 templates with different questions and answers file defaults build_file_tree( { (tpl1 / "copier.yml"): "q1: a1", (tpl1 / "{{ _copier_conf.answers_file }}.jinja"): ( "{{ _copier_answers|to_nice_yaml }}" ), (tpl2 / "copier.yml"): "{_answers_file: two.yml, q2: a2}", (tpl2 / "{{ _copier_conf.answers_file }}.jinja"): ( "{{ _copier_answers|to_nice_yaml }}" ), } ) # Make them git-tracked for tpl in (tpl1, tpl2): with local.cwd(tpl): git("init") git("add", "-A") git("commit", "-m1") with local.cwd(dst): # Apply template 1 run_copy(str(tpl1), overwrite=True, defaults=True) git("init") git("add", "-A") git("commit", "-m1") # Apply template 2 run_copy(str(tpl2), overwrite=True, defaults=True) git("init") git("add", "-A") git("commit", "-m2") # Check contents answers1 = load_answersfile_data(".") assert answers1["_src_path"] == str(tpl1) assert answers1["q1"] == "a1" assert "q2" not in answers1 answers2 = load_answersfile_data(".", "two.yml") assert answers2["_src_path"] == str(tpl2) assert answers2["q2"] == "a2" assert "q1" not in answers2 def test_omit_answer_for_skipped_question( tmp_path_factory: pytest.TempPathFactory, ) -> None: src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) build_file_tree( { (src / "copier.yml"): ( """\ disabled: type: str when: false disabled_with_default: type: str default: hello when: false """ ), (src / "{{ _copier_conf.answers_file }}.jinja"): ( "{{ _copier_answers|to_nice_yaml }}" ), (src / "context.yml.jinja"): yaml.safe_dump( { "disabled": "{{ disabled }}", "disabled_with_default": "{{ disabled_with_default }}", } ), } ) run_copy(str(src), dst, defaults=True, data={"disabled": "hello"}) answers = load_answersfile_data(dst) assert answers == {"_src_path": str(src)} context = yaml.safe_load((dst / "context.yml").read_text()) assert context == {"disabled": "hello", "disabled_with_default": "hello"}