diff --git a/copier/_template.py b/copier/_template.py index ce6313134..119d9513a 100644 --- a/copier/_template.py +++ b/copier/_template.py @@ -63,6 +63,290 @@ def filter_config(data: AnyByStrDict) -> tuple[AnyByStrDict, AnyByStrDict]: return config_data, questions_data +def get_include_file_N_condition(_include_file: str) -> tuple[str, Any | None]: + """Parse the YAML include statement (e.g. `./include_me.yml when {{ var }}`, `"./include_me.yaml"`), and return the YAML file path, and the condition if a condition is present. + + Args: + _include_file: input + + Returns: + (_yaml_file_path, _condition): + _yaml_file_path: YAML file path + _condition: the condition if a condition is present + """ + _match = re.match( + r"((['\"]?).*?\.ya?ml\2)(?: when (['\"]?)(.*)\3)?", + # Note that `(?: )` is a non-cpaturing group + # group 0 (`\1`): wrapped or not in `"` or `'`, string finishing by `yaml`/`yml` extension + # group 1 (`\2`) [optional]: `"` or `'` starting the YAML file path + # group 2 (`\3`) [optional]: `"` or `'` starting the condition + # group 3 (`\4`) [optional]: the condition string not wrapped in `'` or `"` + _include_file, + ) + if _match is not None: + _include_file = _match.groups()[0] + _condition = _match.groups()[3] + return _include_file, _condition + raise ValueError( + f"`_include_file` args is not in a correct format, got: `{_include_file}`\nExpected a string like:`\"sub_questions.yml\"` or `'./sub_dir/sub_questions.yaml' when {'{{var}}'}`" + ) + + +def trim_cond(cond: str) -> str: + """Remove `{{ ` and ` }}`. + + Args: + cond: input + + Returns: + str: str without `{{ ` and ` }}` + + """ + # TODO: might have to condition it more strictly `^\{\{ (.*?) \}\}$`? + return cond.replace("{{ ", "").replace(" }}", "") + + +def condition_questions(_dict: dict[str, Any], _condition: str) -> None: + """Modify in-place the `question` var.s to jinja condition them on **_condition**, and default them to falsy values if **_condition** is false. + + Args: + _dict: dict of yaml loaded elements + _condition: jinja condition str + + """ + # bool, float, int, json, path, str, yaml (default) + _cond_expr = _condition + jinja2_type_default_dict = { + "bool": False, + "float": 0.0, + "int": 0, + "json": "{}", # Have to pass a JsonDecode + "path": "", # Converted to '' + "str": "", + "yaml": "", # Converted to `null` + } + for _key, _val in _dict.items(): + if _key[0] != "_" and isinstance(_val, dict): + jinja2_type_default = ( + f"{jinja2_type_default_dict.get(_val.get('type', ''), '')}" + ) + _val = _val.update( + { + "when": f"{{{{ {trim_cond(_val.get('when', 'True'))} if ({_condition}) else False }}}}", + "default": f"{{% if ({_condition}) %}}{_val.get('default', '')}{{% else %}}{jinja2_type_default}{{% endif %}}", + } + ) + + +def condition_settings(_dict: dict[str, Any], _condition_short: str) -> None: + """Modify in-place the settings, if they exist, to 'jinja' condition them on **_condition_short**. + + Args: + _dict: dict of yaml loaded elements + _condition_short: jinja condition str + """ + # name, type + settings = ( + ("answers_file", str), + # `cleanup_on_error`: Not supported in copier.yml. + # `conflict`: Not supported in copier.yml. + # `context_lines`: Not supported in copier.yml. + # `data`: Cannot be defined in copier.yml. + # `data_file`: Not supported in copier.yml. + ("external_data", dict[str, str]), + ("envops", dict), + ("exclude", list[str]), + # `force`: Not supported in copier.yml. + # `defaults`: Not supported in copier.yml. + # `overwrite`: Not supported in copier.yml. + ("jinja_extensions", list[str]), # TODO: Check if it is interpreted by jinja + ("message_after_copy", str), + ("message_after_update", str), + ("message_before_copy", str), + ("message_before_update", str), + # ("migrations", list[str|list[str]|dict]), # if dict => `command`,`when`, transf. list[str] to dict + ("migrations", list), + ("min_copier_version", str), + # `pretend`: Not supported in copier.yml. + ("preserve_symlinks", bool), + # `quiet`: Not supported in copier.yml. + ("secret_questions", list[str]), + # `skip_answered`: Not supported in copier.yml. + ("skip_if_exists", list[str]), + ("skip_tasks", bool), + ("subdirectory", str), + # ("tasks", list[str|list[str]|dict]), # if dict => `command`,`when`, transf. list[str] to dict + ("tasks", list), + ("templates_suffix", str), + # `unsafe`: Not supported in copier.yml. + # `use_prereleases`: Not supported in copier.yml. + # `vcs_ref`: Not supported in copier.yml. + ) + for _setting, _types_GenericAlias in settings: + if _types_GenericAlias in (str, bool) and f"_{_setting}" in _dict: + _dict[f"_{_setting}"] = ( + f"{{% if ({_condition_short}) %}}{_dict[f'_{_setting}']}{{% endif %}}" + ) + if _types_GenericAlias == list[str] and f"_{_setting}" in _dict: + _dict[f"_{_setting}"] = [ + f"{{% if ({_condition_short}) %}}{_elem}{{% endif %}}" + for _elem in _dict[f"_{_setting}"] + ] + if _setting in ("tasks", "migrations") and f"_{_setting}" in _dict: + _tmp = [] + for _elem in _dict[f"_{_setting}"]: + if type(_elem) in (str, list): + _tmp.append( + {"command": _elem, "when": f"{{{{ {_condition_short} }}}}"} + ) + if isinstance(_elem, dict): + _elem["when"] = ( + f"{{% if ({_condition_short}) %}}{_elem['when']}{{% endif %}}" + ) + _tmp.append(_elem) + + _dict[f"_{_setting}"] = _tmp + + if _setting in ("envops", "external_data") and f"_{_setting}" in _dict: + _dict[f"_{_setting}"] = { + _key: f"{{% if ({_condition_short}) %}}{_val}{{% endif %}}" + for _key, _val in _dict[f"_{_setting}"].items() + } + + +def jinja_str_to_f_str(_jinja_str: str) -> str: + """Replace jinja var indicator `{{` and `}}` by python f-string var `{` and `}`, if present, and return the string as the exprression of an f-string. + + Args: + _jinja_str: The input str + + Returns: + str: Modified str + + """ + if _jinja_str.find("{{") >= 0: + return 'f"' + _jinja_str.replace("{{", "{").replace("}}", "}") + '"' + + return f'"{_jinja_str}"' + + +def transform_jinja_cond_to_jinja_var(_cond: str) -> tuple[str, bool]: + """Parse the jinja condition string, and return the condition as a "jinja variable". + + Args: + _cond: The jinja condition str + + Returns: + _cond_jinja_var, _is_str: + _cond_jinja_var: the condition as a "jinja variable" + _is_str: the condition above a str, or an expression + """ + if _match := re.match(r"^\{% if (.+?) %\}(.+)", _cond): + _if_cond = _match.groups()[0] # if statement + _cond, _cond_to_str = transform_jinja_cond_to_jinja_var( + _match.groups()[1] + ) # Check for a nested if statement + + _if_elif_statement = [] + _elif_cond = [] + while _match := re.match(r"^(.+?)\{% elif (.+?) %\}(.+)", _cond): + _if_elif_statement.append( + jinja_str_to_f_str(_match.groups()[0]) + if _cond_to_str + else _match.groups()[0] + ) + _elif_cond.append(_match.groups()[1]) + _cond, _cond_to_str = transform_jinja_cond_to_jinja_var(_match.groups()[2]) + + if _match := re.match(r"^(.+?)\{% else %\}(.+)", _cond): + _if_elif_statement.append( + jinja_str_to_f_str(_match.groups()[0]) + if _cond_to_str + else _match.groups()[0] + ) + _cond, _cond_to_str = transform_jinja_cond_to_jinja_var(_match.groups()[1]) + if _match := re.match(r"^(.+?)\{% endif %\}(.*)", _cond): + _if_elif_statement.append( + jinja_str_to_f_str(_match.groups()[0]) + if _cond_to_str + else _match.groups()[0] + ) + _cond_end = _match.groups()[1] + else: + raise ValueError("Condition does not end.") + + _res = " ".join( + ( + f"({_if_elif_statement[0]}) if ({_if_cond})", + *( + f"else ({_val}) if ({_if})" + for _if, _val in zip(_elif_cond, _if_elif_statement[1:-1]) + ), + f"else ({_if_elif_statement[-1]})", + ) + ) + if _cond_end: + _res += _cond_end + return (_res, False) + + elif _match := re.match(r"\{\{ (.*) \}\}", _cond): + return _match.groups()[0], False + return _cond, True + + +def apply_condition(_dict: dict[str, Any], _condition: str) -> None: + """Modify in-place the **_dict** elements to be 'jinja' conditioned on **_condition**. + + Args: + _dict: dict of yaml loaded elements + _condition: jinja str condition + """ + if _match := re.match(r"{{ (.*?) }}", _condition): + _condition_short = _match.groups()[0] + else: + _condition_short = transform_jinja_cond_to_jinja_var(_condition)[0] + # _match = re.match(r"{{ (.*?) }}", _condition) + # _condition_short = _match.groups()[0] if _match is not None else _condition + condition_settings(_dict, _condition_short) + # condition_questions(_dict, _condition) + condition_questions(_dict, _condition_short) + + +def condition_include( + conf_path: Path, + loader: yaml.BaseLoader + | yaml.FullLoader + | yaml.SafeLoader + | yaml.Loader + | yaml.UnsafeLoader, + include_file: str, + _condition: str | None, +) -> dict[str, Any]: + """Get the dict of YAML loaded elements 'jinja' conditioned on **_condition**. + + Args: + conf_path: The path to the `copier.yml` file. + loader: The YAML loader + include_file: The YAML file path + _condition: jinja include condition + + Returns: + dict: The dict of YAML loaded elements 'jinja' conditioned on **_condition** + """ + _tmp = {} + _res = [ + lflatten(filter(None, yaml.load_all(path.read_bytes(), Loader=type(loader)))) + for path in conf_path.parent.glob(include_file) + ] + for _elem in _res: + for _sub in _elem: + _tmp.update(_sub) + if _condition: + apply_condition(_tmp, _condition) + + return _tmp + + def load_template_config(conf_path: Path, quiet: bool = False) -> AnyByStrDict: """Load the `copier.yml` file. @@ -86,15 +370,13 @@ class _Loader(yaml.FullLoader): def _include(loader: yaml.Loader, node: yaml.Node) -> Any: if not isinstance(node, yaml.ScalarNode): raise ValueError(f"Unsupported YAML node: {node!r}") - include_file = str(loader.construct_scalar(node)) + include_file, _condition = get_include_file_N_condition( + str(loader.construct_scalar(node)) + ) if PurePosixPath(include_file).is_absolute(): raise ValueError("YAML include file path must be a relative path") - return [ - lflatten( - filter(None, yaml.load_all(path.read_bytes(), Loader=type(loader))) - ) - for path in conf_path.parent.glob(include_file) - ] + + return condition_include(conf_path, loader, include_file, _condition) _Loader.add_constructor("!include", _include) diff --git a/pyproject.toml b/pyproject.toml index 08b113c25..e3fd16fe8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -158,7 +158,10 @@ disable_error_code = ["no-untyped-def"] [tool.pytest.ini_options] addopts = "-n auto -ra" -markers = ["impure: needs network or is not 100% reproducible"] +markers = [ + "impure: needs network or is not 100% reproducible", + "conditional_transclusion: marks tests as conditional_transclusion (deselect with '-m \"not conditional_transclusion\"')", +] [tool.coverage.run] omit = [ diff --git a/tests/demo_transclude_conditional/demo/copier.yml b/tests/demo_transclude_conditional/demo/copier.yml new file mode 100644 index 000000000..caa465762 --- /dev/null +++ b/tests/demo_transclude_conditional/demo/copier.yml @@ -0,0 +1,27 @@ + +condition: + type: bool + default: True + + +--- +!include ./include_me.yml +when "{{ condition }}" +--- +!include ./include_me_also.yml +--- + +_exclude: + - "copier.yml" + + +env_type: + type: str + choices: + - dev + - run + +--- +!include env/dev.yml +when "{{ env_type == 'dev' }}" +--- diff --git a/tests/demo_transclude_conditional/demo/env/dev.yml b/tests/demo_transclude_conditional/demo/env/dev.yml new file mode 100644 index 000000000..375686320 --- /dev/null +++ b/tests/demo_transclude_conditional/demo/env/dev.yml @@ -0,0 +1,3 @@ + +_exclude: + - env/dev.yml diff --git a/tests/demo_transclude_conditional/demo/include_me.yml b/tests/demo_transclude_conditional/demo/include_me.yml new file mode 100644 index 000000000..35cb64964 --- /dev/null +++ b/tests/demo_transclude_conditional/demo/include_me.yml @@ -0,0 +1,38 @@ + +var: + type: int + default: 1 + +the_str: + type: str + # when: "{{ var>0 }}" + +test: + # type: "{% if var %}bool{% else %}str{% endif %}" + type: "{{ 'bool' if var else 'str' }}" + # default: "{% if var %}false{% else %}my_str{% endif %}" + default: "{{ '' if var else 'my_str' }}" + + +test_json: + type: json + default: '{"test":1}' + +test_path: + type: path + default: './tests' + +test_yaml: + type: yaml + default: + test: + type: bool + + + + +_exclude: + - "include_me.yml" + + + diff --git a/tests/demo_transclude_conditional/demo/include_me_also.yml b/tests/demo_transclude_conditional/demo/include_me_also.yml new file mode 100644 index 000000000..e00e49995 --- /dev/null +++ b/tests/demo_transclude_conditional/demo/include_me_also.yml @@ -0,0 +1,2 @@ +_exclude: + - "include_me_also.yml" diff --git a/tests/demo_transclude_conditional/demo/{% if not_exist %}test_none.py{% endif %} b/tests/demo_transclude_conditional/demo/{% if not_exist %}test_none.py{% endif %} new file mode 100644 index 000000000..e69de29bb diff --git a/tests/demo_transclude_conditional/demo/{% if test %}test.py{% endif %} b/tests/demo_transclude_conditional/demo/{% if test %}test.py{% endif %} new file mode 100644 index 000000000..e69de29bb diff --git a/tests/demo_transclude_conditional/demo/{% if test_json %}test_json.py{% endif %} b/tests/demo_transclude_conditional/demo/{% if test_json %}test_json.py{% endif %} new file mode 100644 index 000000000..e69de29bb diff --git a/tests/demo_transclude_conditional/demo/{% if test_path %}test_path.py{% endif %} b/tests/demo_transclude_conditional/demo/{% if test_path %}test_path.py{% endif %} new file mode 100644 index 000000000..e69de29bb diff --git a/tests/demo_transclude_conditional/demo/{% if test_yaml %}test_yaml.py{% endif %} b/tests/demo_transclude_conditional/demo/{% if test_yaml %}test_yaml.py{% endif %} new file mode 100644 index 000000000..e69de29bb diff --git a/tests/demo_transclude_conditional/demo/{% if var %}var.py{% endif %} b/tests/demo_transclude_conditional/demo/{% if var %}var.py{% endif %} new file mode 100644 index 000000000..e69de29bb diff --git a/tests/demo_transclude_conditional/demo/{{ _copier_conf.answers_file }}.jinja b/tests/demo_transclude_conditional/demo/{{ _copier_conf.answers_file }}.jinja new file mode 100644 index 000000000..a96840d68 --- /dev/null +++ b/tests/demo_transclude_conditional/demo/{{ _copier_conf.answers_file }}.jinja @@ -0,0 +1,2 @@ +# Changes here will be overwritten by Copier +{{ _copier_answers|to_nice_yaml -}} diff --git a/tests/demo_transclude_conditional/desc.md b/tests/demo_transclude_conditional/desc.md new file mode 100644 index 000000000..269b5e812 --- /dev/null +++ b/tests/demo_transclude_conditional/desc.md @@ -0,0 +1,110 @@ +# config + +## vars + +- `condition` (`bool`, def.: `True`) + +if `condition`: + +- `var` (`int`, def.:`1`) +- `the_str` (`str`) +- `test` (`{{ 'bool' if var else 'str' }}`, def.: `{{ '' if var else 'my_str' }}`) + +## `_exclude` + +- `copier.yml` +- `include_me_also.yml` + +if `condition`: + +- `include_me.yml` + +## Files + +- `{% if test %}test.py{% endif %}` +- `{% if not_exist %}test_none.py{% endif %}` + +# Use case + +## Initial + +| name | type | default | value | when | +| :---------- | :------------------------------- | :---------------------------------------------------------------------- | :---- | :--- | +| `condition` | `bool` | `True` | | `O` | +| `var` | `int` | `1` | | `` | +| `the_str` | `str` | `"my_str"` | | `` | +| `test` | `{{ 'bool' if var else 'str' }}` | `{{ '' if var else 'my_str' }}` | | `` | +| `_exclude` | | `copier.yml`, `include_me_also.yml`, (if `condition`: `include_me.yml`) | | + +Files: + +- `{% if var %}var.py{% endif %}` +- `{% if test %}test.py{% endif %}` +- `{% if not_exist %}test_none.py{% endif %}` + +## 1. + +```py +{ + "condition": True, + "var": 1, + "the_str": "my_str", + "test": False, +}, +``` + +| name | type | default | value | when | +| :---------- | :----- | :---------------------------------------------------- | :--------- | :--- | +| `condition` | `bool` | `True` | `True` | `O` | +| `var` | `int` | `1` | `1` | `O` | +| `the_str` | `str` | `"my_str"` | `"my_str"` | `O` | +| `test` | `bool` | `''` | `False` | `O` | +| `_exclude` | | `copier.yml`, `include_me_also.yml`, `include_me.yml` | | + +Files: + +- `var.py` + +## 2. + +```py +{ + "condition": False, + "var": None, + "the_str": None, + "test": None, +}, +``` + +| name | type | default | value | when | +| :---------- | :------------------------------- | :---------------------------------- | :------ | :--- | +| `condition` | `bool` | `True` | `False` | `O` | +| `var` | `int` | `1` | | `X` | +| `the_str` | `str` | `"my_str"` | | `X` | +| `test` | `{{ 'bool' if var else 'str' }}` | `{{ '' if var else 'my_str' }}` | | `X` | +| `_exclude` | | `copier.yml`, `include_me_also.yml` | | + +Files: None + +## 3. + +```py +{ + "condition": True, + "var": 0, + "the_str": "my_str", + "test": None, +}, +``` + +| name | type | default | value | when | +| :---------- | :----- | :---------------------------------------------------- | :--------- | :--- | +| `condition` | `bool` | `True` | `True` | `O` | +| `var` | `int` | `1` | `0` | `O` | +| `the_str` | `str` | `"my_str"` | `"my_str"` | `O` | +| `test` | `str` | `'my_str'` | `'my_str'` | `O` | +| `_exclude` | | `copier.yml`, `include_me_also.yml`, `include_me.yml` | | + +Files: + +- `test.py` diff --git a/tests/test_config.py b/tests/test_config.py index d0d5d5759..18aa3c0dd 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,17 +1,32 @@ from __future__ import annotations import json +from contextlib import nullcontext from pathlib import Path from textwrap import dedent from typing import Any, Callable import pytest +import yaml from plumbum import local from pydantic import ValidationError import copier from copier._main import Worker -from copier._template import DEFAULT_EXCLUDE, Task, Template, load_template_config +from copier._template import ( + DEFAULT_EXCLUDE, + Task, + Template, + apply_condition, + condition_include, + condition_questions, + condition_settings, + get_include_file_N_condition, + jinja_str_to_f_str, + load_template_config, + transform_jinja_cond_to_jinja_var, + trim_cond, +) from copier._types import AnyByStrDict from copier.errors import InvalidConfigFileError, MultipleConfigFilesError @@ -722,3 +737,566 @@ def test_secret_question_requires_default_value( build_file_tree({src / "copier.yml": config}) with pytest.raises(ValueError, match="Secret question requires a default value"): copier.run_copy(str(src), dst) + + +##### conditional_transclusion ##### + + +@pytest.mark.conditional_transclusion +@pytest.mark.parametrize( + "input, output, is_error", + ( + ("./include_me.yml when {{ var }}", ("./include_me.yml", "{{ var }}"), False), + ("./include_me.yaml", ("./include_me.yaml", None), False), + ("'common_*.yml'", ("'common_*.yml'", None), False), + ("not legit", (None, None), True), + ), +) +def test_get_include_file_N_condition( + input: str, output: tuple[str | None, str | None], is_error: bool +) -> None: + with pytest.raises(ValueError) if is_error else nullcontext(): + assert get_include_file_N_condition(input) == output + + +@pytest.mark.conditional_transclusion +@pytest.mark.parametrize( + "input, output", + (("{{ test }}", "test"), ("test {{ ", "test "), ("}} test ", "}} test ")), +) +def test_trim_cond(input: str, output: str) -> None: + assert trim_cond(input) == output + + +@pytest.mark.conditional_transclusion +@pytest.mark.parametrize( + "_dict, _condition, output", + ( + ( + { + "var": {"type": "int", "default": 1}, + }, + "condition", + { + "var": { + "type": "int", + "default": "{% if (condition) %}1{% else %}0{% endif %}", + "when": "{{ True if (condition) else False }}", + }, + }, + ), + ( + { + "the_str": {"type": "str"}, + }, + "condition", + { + "the_str": { + "type": "str", + "when": "{{ True if (condition) else False }}", + "default": "{% if (condition) %}{% else %}{% endif %}", + }, + }, + ), + ( + { + "test": { + "type": "{{ 'bool' if var else 'str' }}", + "default": "{{ '' if var else 'my_str' }}", + }, + }, + "condition", + { + "test": { + "type": "{{ 'bool' if var else 'str' }}", + "default": "{% if (condition) %}{{ '' if var else 'my_str' }}{% else %}{% endif %}", + "when": "{{ True if (condition) else False }}", + }, + }, + ), + ( + { + "_exclude": ["{% if condition %}include_me.yml{% endif %}"], + }, + "condition", + { + "_exclude": ["{% if condition %}include_me.yml{% endif %}"], + }, + ), + # ({}, "", {}), + ), +) +def test_condition_questions( + _dict: dict[str, Any], _condition: str, output: dict[str, Any] +) -> None: + condition_questions(_dict, _condition) + assert _dict == output + + +@pytest.mark.conditional_transclusion +@pytest.mark.parametrize( + "input, output", + ( + ({}, {}), + ( + { + "_answers_file": ".my-custom-answers.yml", + "_external_data": { + "parent_tpl": "{{ parent_tpl_answers_file }}", + "secrets": ".secrets.yaml", + }, + "_envops": { + "autoescape": "false", + "block_end_string": "%]", + "block_start_string": "[%", + "comment_end_string": "#]", + "comment_start_string": "[#", + "keep_trailing_newline": "true", + "variable_end_string": "]]", + "variable_start_string": "[[", + }, + "_exclude": [ + "include_me.yml", + "{% if _copier_operation == 'update' -%}src/*_example.py{% endif %}", + "*.bar", + ".git", + ], + "_jinja_extensions": [ + "jinja_markdown.MarkdownExtension", + "jinja2_slug.SlugExtension", + "jinja2_time.TimeExtension", + ], + "_message_after_copy": 'Your project "{{ project_name }}" has been created successfully!\n\nNext steps:\n\n1. Change directory to the project root:\n\n\t$ cd {{ _copier_conf.dst_path }}\n\n2. Read "CONTRIBUTING.md" and start coding.', + "_message_after_update": 'Your project "{{ project_name }}" has been updated successfully!\nIn case there are any conflicts, please resolve them. Then,\nyou\'re done.', + "_message_before_copy": "Thanks for generating a project using our template.\n\nYou'll be asked a series of questions whose answers will be used to\ngenerate a tailored project for you.", + "_message_before_update": "Thanks for updating your project using our template.\n\nYou'll be asked a series of questions whose answers are pre-populated\nwith previously entered values. Feel free to change them as needed.", + "_migrations": [ + "invoke -r {{ _copier_conf.src_path }} -c migrations migrate $STAGE $VERSION_FROM $VERSION_TO", + { + "version": "v1.0.0", + "command": "rm ./old-folder", + "when": "{{ _stage == 'before' }}", + }, + ], + "_min_copier_version": "4.1.0", + "_preserve_symlinks": "True", + "_secret_questions": [ + "password", + ], + "_skip_if_exists": [ + ".secret_password.yml", + ], + "_skip_tasks": "{{ condition }}", + "_subdirectory": "{{ python_engine }}", + "_tasks": [ + "git init", + "rm {{ name_of_the_project }}/README.md", + [ + "invoke", + "--search-root={{ _copier_conf.src_path }}", + "after-copy", + ], + ["invoke", "end-process", "--full-conf={{ _copier_conf|to_json }}"], + ["{{ _copier_python }}", "task.py"], + { + "command": ["{{ _copier_python }}", "task.py"], + "when": "{{ _copier_operation == 'copy' }}", + }, + { + "command": "rm {{ name_of_the_project }}/README.md", + "when": "{{ _copier_conf.os in ['linux', 'macos'] }}", + }, + { + "command": "Remove-Item {{ name_of_the_project }}\\README.md", + "when": "{{ _copier_conf.os == 'windows' }}", + }, + ], + "_templates_suffix": ".my-custom-suffix", + }, + ### Output ### + { + "_answers_file": "{% if (condition) %}.my-custom-answers.yml{% endif %}", + "_external_data": { + "parent_tpl": "{% if (condition) %}{{ parent_tpl_answers_file }}{% endif %}", + "secrets": "{% if (condition) %}.secrets.yaml{% endif %}", + }, + "_envops": { + "autoescape": "{% if (condition) %}false{% endif %}", + "block_end_string": "{% if (condition) %}%]{% endif %}", + "block_start_string": "{% if (condition) %}[%{% endif %}", + "comment_end_string": "{% if (condition) %}#]{% endif %}", + "comment_start_string": "{% if (condition) %}[#{% endif %}", + "keep_trailing_newline": "{% if (condition) %}true{% endif %}", + "variable_end_string": "{% if (condition) %}]]{% endif %}", + "variable_start_string": "{% if (condition) %}[[{% endif %}", + }, + "_exclude": [ + "{% if (condition) %}include_me.yml{% endif %}", + "{% if (condition) %}{% if _copier_operation == 'update' -%}src/*_example.py{% endif %}{% endif %}", + "{% if (condition) %}*.bar{% endif %}", + "{% if (condition) %}.git{% endif %}", + ], + "_jinja_extensions": [ + "{% if (condition) %}jinja_markdown.MarkdownExtension{% endif %}", + "{% if (condition) %}jinja2_slug.SlugExtension{% endif %}", + "{% if (condition) %}jinja2_time.TimeExtension{% endif %}", + ], + "_message_after_copy": '{% if (condition) %}Your project "{{ project_name }}" has been created successfully!\n\nNext steps:\n\n1. Change directory to the project root:\n\n\t$ cd {{ _copier_conf.dst_path }}\n\n2. Read "CONTRIBUTING.md" and start coding.{% endif %}', + "_message_after_update": '{% if (condition) %}Your project "{{ project_name }}" has been updated successfully!\nIn case there are any conflicts, please resolve them. Then,\nyou\'re done.{% endif %}', + "_message_before_copy": "{% if (condition) %}Thanks for generating a project using our template.\n\nYou'll be asked a series of questions whose answers will be used to\ngenerate a tailored project for you.{% endif %}", + "_message_before_update": "{% if (condition) %}Thanks for updating your project using our template.\n\nYou'll be asked a series of questions whose answers are pre-populated\nwith previously entered values. Feel free to change them as needed.{% endif %}", + "_migrations": [ + { + "command": "invoke -r {{ _copier_conf.src_path }} -c migrations migrate $STAGE $VERSION_FROM $VERSION_TO", + "when": "{{ condition }}", + }, + { + "version": "v1.0.0", + "command": "rm ./old-folder", + "when": "{% if (condition) %}{{ _stage == 'before' }}{% endif %}", + }, + ], + "_min_copier_version": "{% if (condition) %}4.1.0{% endif %}", + "_preserve_symlinks": "{% if (condition) %}True{% endif %}", + "_secret_questions": [ + "{% if (condition) %}password{% endif %}", + ], + "_skip_if_exists": [ + "{% if (condition) %}.secret_password.yml{% endif %}", + ], + "_skip_tasks": "{% if (condition) %}{{ condition }}{% endif %}", + "_subdirectory": "{% if (condition) %}{{ python_engine }}{% endif %}", + "_tasks": [ + {"command": "git init", "when": "{{ condition }}"}, + { + "command": "rm {{ name_of_the_project }}/README.md", + "when": "{{ condition }}", + }, + { + "command": [ + "invoke", + "--search-root={{ _copier_conf.src_path }}", + "after-copy", + ], + "when": "{{ condition }}", + }, + { + "command": [ + "invoke", + "end-process", + "--full-conf={{ _copier_conf|to_json }}", + ], + "when": "{{ condition }}", + }, + { + "command": ["{{ _copier_python }}", "task.py"], + "when": "{{ condition }}", + }, + { + "command": ["{{ _copier_python }}", "task.py"], + "when": "{% if (condition) %}{{ _copier_operation == 'copy' }}{% endif %}", + }, + { + "command": "rm {{ name_of_the_project }}/README.md", + "when": "{% if (condition) %}{{ _copier_conf.os in ['linux', 'macos'] }}{% endif %}", + }, + { + "command": "Remove-Item {{ name_of_the_project }}\\README.md", + "when": "{% if (condition) %}{{ _copier_conf.os == 'windows' }}{% endif %}", + }, + ], + "_templates_suffix": "{% if (condition) %}.my-custom-suffix{% endif %}", + }, + ), + ), +) +def test_condition_settings(input: dict[str, Any], output: dict[str, Any]) -> None: + condition_settings(input, "condition") + assert input == output + + +@pytest.mark.conditional_transclusion +@pytest.mark.parametrize( + "input, output", + ( + ("{{var}}", 'f"{var}"'), + ("Test: {{var}} number", 'f"Test: {var} number"'), + ("Hello world!", '"Hello world!"'), + ), +) +def test_jinja_str_to_f_str(input: str, output: str) -> None: + assert jinja_str_to_f_str(input) == output + + +@pytest.mark.conditional_transclusion +@pytest.mark.parametrize( + "_cond, output", + ( + ("{{ condition }}", "condition"), + ( + "{% if var>0 %}Welcome {{user}}!{% else %}{% if polite %}Goodbye{% else %}Bye{% endif %}{% endif %}", + '(f"Welcome {user}!") if (var>0) else (("Goodbye") if (polite) else ("Bye"))', + ), + ( + "{% if var>0 %}Welcome {{user}}!{% elif var<-2 %}Hello!{% else %}{% if polite %}Goodbye{% else %}Bye{% endif %}{% endif %}", + '(f"Welcome {user}!") if (var>0) else ("Hello!") if (var<-2) else (("Goodbye") if (polite) else ("Bye"))', + ), + # ("", ""), + ), +) +def test_transform_jinja_cond_to_jinja_var(_cond: str, output: str) -> None: + assert transform_jinja_cond_to_jinja_var(_cond)[0] == output + + +@pytest.mark.conditional_transclusion +@pytest.mark.parametrize( + "_cond", + ( + "{% if var>0 %}Welcome {{user}}!{% else %}{% if polite %}Goodbye{% else %}Bye{% endif %}", + "{% if var>0 %}Welcome {{user}}!{% elif var<-2 %}Hello!{% else %}{% if polite %}Goodbye{% else %}Bye{% endif %}", + "{% if var>0 %}Welcome {{user}}!{% elif var<-2 %}{% if polite %}Goodbye{% else %}Bye{% endif %}{% else %}Hello!", + "{% if var>0 %}Welcome {{user}}!{% elif var<-2 %}{% if polite %}Goodbye{% else %}Bye{% else %}Hello!{% endif %}", + ), +) +def test_transform_jinja_cond_to_jinja_var_Error(_cond: str) -> None: + with pytest.raises(ValueError): + transform_jinja_cond_to_jinja_var(_cond) + + +@pytest.mark.conditional_transclusion +@pytest.mark.parametrize( + "_dict, _condition, output", + ( + ( + { + "var": {"type": "int", "default": 1}, + }, + "{{ condition }}", + { + "var": { + "type": "int", + "default": "{% if (condition) %}1{% else %}0{% endif %}", + "when": "{{ True if (condition) else False }}", + }, + }, + ), + ( + { + "the_str": {"type": "str"}, + }, + "{{ condition }}", + { + "the_str": { + "type": "str", + "when": "{{ True if (condition) else False }}", + "default": "{% if (condition) %}{% else %}{% endif %}", + }, + }, + ), + ( + { + "test": { + "type": "{{ 'bool' if var else 'str' }}", + "default": "{{ '' if var else 'my_str' }}", + }, + }, + "{{ condition }}", + { + "test": { + "type": "{{ 'bool' if var else 'str' }}", + "default": "{% if (condition) %}{{ '' if var else 'my_str' }}{% else %}{% endif %}", + "when": "{{ True if (condition) else False }}", + }, + }, + ), + ( + { + "_exclude": ["include_me.yml"], + }, + "{{ condition }}", + { + "_exclude": ["{% if (condition) %}include_me.yml{% endif %}"], + }, + ), + ( + { + "_exclude": ["include_me.yml"], + }, + "{% if condition %}True{% else %}False{% endif %}", + { + "_exclude": [ + '{% if (("True") if (condition) else ("False")) %}include_me.yml{% endif %}' + ], + }, + ), + # ({}, "", {}), + ), +) +def test_apply_condition( + _dict: dict[str, Any], _condition: str, output: dict[str, Any] +) -> None: + apply_condition(_dict, _condition) + assert _dict == output + + +@pytest.mark.conditional_transclusion +def test_condition_include() -> None: + class _Loader(yaml.FullLoader): + """Intermediate class to avoid monkey-patching main loader.""" + + conf_path = Path("./tests/demo_transclude_conditional/demo/copier.yml") + output = { + "_exclude": ["{% if (condition) %}include_me.yml{% endif %}"], + "test": { + "default": "{% if (condition) %}{{ '' if var else 'my_str' }}{% else %}{% endif %}", + "type": "{{ 'bool' if var else 'str' }}", + "when": "{{ True if (condition) else False }}", + }, + "test_json": { + "default": '{% if (condition) %}{"test":1}{% else %}{}{% endif %}', + "type": "json", + "when": "{{ True if (condition) else False }}", + }, + "test_path": { + "default": "{% if (condition) %}./tests{% else %}{% endif %}", + "type": "path", + "when": "{{ True if (condition) else False }}", + }, + "test_yaml": { + "default": "{% if (condition) %}{'test': {'type': 'bool'}}{% else %}{% endif %}", + "type": "yaml", + "when": "{{ True if (condition) else False }}", + }, + "the_str": { + "default": "{% if (condition) %}{% else %}{% endif %}", + "type": "str", + "when": "{{ True if (condition) else False }}", + }, + "var": { + "default": "{% if (condition) %}1{% else %}0{% endif %}", + "type": "int", + "when": "{{ True if (condition) else False }}", + }, + } + with conf_path.open("rb") as f: + assert ( + condition_include( + conf_path=conf_path, + loader=_Loader(f), + include_file="./include_me.yml", + _condition="{{ condition }}", + ) + == output + ) + + +@pytest.mark.conditional_transclusion +@pytest.mark.parametrize( + "inputs, output_files, answer_file, src", + ( + ( + { + "condition": True, + "var": 1, + "the_str": "my_str", + "test": False, + "env_type": "dev", + }, + { + ".copier-answers.yml": True, + "test_none.py": False, + "test.py": False, + "test_json.py": True, + "test_path.py": True, + "test_yaml.py": True, + "var.py": True, + "copier.yml": False, + "include_me.yml": False, + "include_me_also.yml": False, + "env/dev.yml": False, + }, + f"# Changes here will be overwritten by Copier\n_src_path: tests/demo_transclude_conditional/demo\ncondition: true\nenv_type: dev\ntest: false\ntest_json:\n{''.join([' '] * 4)}test: 1\ntest_path: ./tests\ntest_yaml:\n{''.join([' '] * 4)}test:\n{''.join([' '] * 8)}type: bool\nthe_str: my_str\nvar: 1\n", + "tests/demo_transclude_conditional/demo", + ), + ( + { + "condition": False, + "var": None, + "the_str": None, + "test": None, + "env_type": "run", + }, + { + ".copier-answers.yml": True, + "test_none.py": False, + "test.py": False, + "test_json.py": False, + "test_path.py": False, + "test_yaml.py": False, + "var.py": False, + "copier.yml": False, + "include_me.yml": True, + "include_me_also.yml": False, + "env/dev.yml": True, + }, + "# Changes here will be overwritten by Copier\n_src_path: tests/demo_transclude_conditional/demo\ncondition: false\nenv_type: run\n", + "tests/demo_transclude_conditional/demo", + ), + ( + { + "condition": True, + "var": 0, + "the_str": "my_str", + "test": None, + "env_type": "run", + }, + { + ".copier-answers.yml": True, + "test_none.py": False, + "test.py": True, + "test_json.py": True, + "test_path.py": True, + "test_yaml.py": True, + "var.py": False, + "copier.yml": False, + "include_me.yml": False, + "include_me_also.yml": False, + "env/dev.yml": True, + }, + f"# Changes here will be overwritten by Copier\n_src_path: tests/demo_transclude_conditional/demo\ncondition: true\nenv_type: run\ntest: my_str\ntest_json:\n{''.join([' '] * 4)}test: 1\ntest_path: ./tests\ntest_yaml:\n{''.join([' '] * 4)}test:\n{''.join([' '] * 8)}type: bool\nthe_str: my_str\nvar: 0\n", + "tests/demo_transclude_conditional/demo", + ), + ), +) +def test_conditional_transclusion( + tmp_path_factory: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], + inputs: dict[str, tuple[bool, Any]], + output_files: dict[str, bool], + answer_file: str, + src: str, +) -> None: + dst = tmp_path_factory.mktemp("dst") + + # clear capture output log + capsys.readouterr() + + # copy + _data = {_key: _val for _key, _val in inputs.items() if _val is not None} + print(f"{_data =}") + copier.run_copy( + src, + dst, + data=_data, + quiet=True, + defaults=True, + ) + ## + print("".join(["#"] * 20)) + print((dst / ".copier-answers.yml").read_text()) + ## + for _key, _val in output_files.items(): + assert (dst / _key).exists() == _val, ( + f"File `{_key}` should {'' if _val else 'not'} exist." + ) + assert (dst / ".copier-answers.yml").read_text() == answer_file