From b707c1532789af31a3805f968b5bfade6623580d Mon Sep 17 00:00:00 2001 From: RR5555 Date: Tue, 26 Aug 2025 20:10:56 +0000 Subject: [PATCH 01/11] feat(_template.py): add (crude) conditional transclusion Refs: #1076 --- copier/_template.py | 64 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/copier/_template.py b/copier/_template.py index b26a724f9..83506f17b 100644 --- a/copier/_template.py +++ b/copier/_template.py @@ -63,6 +63,60 @@ def filter_config(data: AnyByStrDict) -> tuple[AnyByStrDict, AnyByStrDict]: return config_data, questions_data +def get_include_file_N_condition(_include_file: str) -> tuple[str, str]: + _match = re.match( + r"(.*?\.ya?ml)(?: when (['\"]?)(.*)\2)?", + _include_file, + ) + _include_file = _match.groups()[0] + _condition = _match.groups()[-1] + return _include_file, _condition + + +def trim_cond(cond: str) -> str: + return cond.replace("{{ ", "").replace(" }}", "") + + +def condition_questions(_dict: dict[str, Any], _condition: str) -> dict[str, Any]: + _cond_expr = trim_cond(_condition) + for _key, _val in _dict.items(): + if _key[0] != "_" and isinstance(_val, dict): + jinja2_type_default = f"{ {'str': '', 'int': 0, 'float': 0.0, 'bool': False}.get(_val.get('type'), '') }" + _val = _val.update( + { + "when": f"{{{{ {trim_cond(_val.get('when', 'True'))} if {_cond_expr} else False }}}}", + "default": f"{{% if {_cond_expr} %}}{_val.get('default', '')}{{% else %}}{jinja2_type_default}{{% endif %}}", + } + ) + return _dict + + +def condition_include( + conf_path: Path, loader: yaml.Loader, include_file: str, _condition: str +) -> dict[str, Any]: + _tmp = {} + _res = [ + yaml.load(path.read_bytes(), Loader=type(loader)) + for path in conf_path.parent.glob(include_file) + ] + for _elem in _res: + _tmp.update(_elem) + if _condition: + _condition_short = re.match(r"{{ (.*?) }}", _condition).groups()[0] + _tmp.update( + { + "_exclude": [ + f"{{% if {_condition_short} %}}{_elem}{{% endif %}}" + for _elem in _tmp["_exclude"] + ] + } + ) + _tmp = condition_questions(_tmp, _condition) + + return _tmp + # return _res + + def load_template_config(conf_path: Path, quiet: bool = False) -> AnyByStrDict: """Load the `copier.yml` file. @@ -86,13 +140,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 [ - yaml.load(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) From 17a9f8c055968c18591e4b5cf2c8e3ddbf23c878 Mon Sep 17 00:00:00 2001 From: RR5555 Date: Tue, 26 Aug 2025 20:18:33 +0000 Subject: [PATCH 02/11] test: add tests for conditional transclusion Some tests are placeholders for now. --- .../demo/copier.yml | 14 +++ .../demo/include_me.yml | 17 +++ .../demo/include_me_also.yml | 2 + .../{% if not_exist %}test_none.py{% endif %} | 0 .../demo/{% if test %}test.py{% endif %} | 0 .../{{ _copier_conf.answers_file }}.jinja | 2 + tests/test_config.py | 110 +++++++++++++++++- 7 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 tests/demo_transclude_conditional/demo/copier.yml create mode 100644 tests/demo_transclude_conditional/demo/include_me.yml create mode 100644 tests/demo_transclude_conditional/demo/include_me_also.yml create mode 100644 tests/demo_transclude_conditional/demo/{% if not_exist %}test_none.py{% endif %} create mode 100644 tests/demo_transclude_conditional/demo/{% if test %}test.py{% endif %} create mode 100644 tests/demo_transclude_conditional/demo/{{ _copier_conf.answers_file }}.jinja diff --git a/tests/demo_transclude_conditional/demo/copier.yml b/tests/demo_transclude_conditional/demo/copier.yml new file mode 100644 index 000000000..92e21a5f2 --- /dev/null +++ b/tests/demo_transclude_conditional/demo/copier.yml @@ -0,0 +1,14 @@ + +condition: + type: bool + default: True + +--- +!include ./include_me.yml +when "{{ condition }}" +--- +!include ./include_me_also.yml +--- + +_exclude: + - "copier.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..6ded05b5f --- /dev/null +++ b/tests/demo_transclude_conditional/demo/include_me.yml @@ -0,0 +1,17 @@ + +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' }}" + +_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/{{ _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/test_config.py b/tests/test_config.py index 2e92728df..9c9aa3fc5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -11,7 +11,15 @@ 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, + condition_questions, + get_include_file_N_condition, + load_template_config, + trim_cond, +) from copier._types import AnyByStrDict from copier.errors import InvalidConfigFileError, MultipleConfigFilesError @@ -717,3 +725,103 @@ 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) + + +@pytest.mark.parametrize( + "input, output", + ( + ("./include_me.yml when {{ var }}", ("./include_me.yml", "{{ var }}")), + ("./include_me.yaml", ("./include_me.yaml", None)), + ), +) +def test_get_include_file_N_condition(input: str, output: tuple[str, str | None]): + assert get_include_file_N_condition(input) == output + + +@pytest.mark.parametrize("input, output", (("{{ test }}", "test"),)) +def test_trim_cond(input: str, output: str) -> None: + assert trim_cond(input) == output + + +@pytest.mark.parametrize("_dict, _condition, output", (({}, "", {}),)) +def test_condition_questions( + _dict: dict[str, Any], _condition: str, output: dict[str, Any] +): + assert condition_questions(_dict, _condition) == output + + +def test_condition_include(): + pass + + +@pytest.mark.parametrize( + "inputs, output_files, answer_file", + ( + # ({"condition": True, "var": None, "the_str": None, "tests": None}), + ( + { + "condition": True, + "var": 1, + "the_str": "my_str", + "test": False, + }, + { + ".copier-answers.yml": True, + "test_none.py": False, + "test.py": False, + "copier.yml": False, + "include_me.yml": False, + "include_me_also.yml": False, + }, + "# Changes here will be overwritten by Copier\n_src_path: tests/demo_transclude_conditional/demo\ncondition: true\ntest: false\nthe_str: my_str\nvar: 1\n", + ), + ( + { + "condition": False, + "var": None, + "the_str": None, + "test": None, + }, + { + ".copier-answers.yml": True, + "test_none.py": False, + "test.py": False, + "copier.yml": False, + "include_me.yml": True, + "include_me_also.yml": False, + }, + "# Changes here will be overwritten by Copier\n_src_path: tests/demo_transclude_conditional/demo\ncondition: false\n", + ), + ), +) +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, +) -> None: + dst = tmp_path_factory.mktemp("dst") + src: str = "tests/demo_transclude_conditional/demo" + + # 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, + ) + ## + 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 From ee69b3d4b305cd48e48b5432a50521b5ddb4f3f5 Mon Sep 17 00:00:00 2001 From: RR5555 Date: Tue, 26 Aug 2025 21:49:24 +0000 Subject: [PATCH 03/11] fix: fix code after merge with up-to-date master --- copier/_template.py | 6 +++--- tests/test_config.py | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/copier/_template.py b/copier/_template.py index e26acb5f8..3c6e2bd27 100644 --- a/copier/_template.py +++ b/copier/_template.py @@ -65,7 +65,7 @@ def filter_config(data: AnyByStrDict) -> tuple[AnyByStrDict, AnyByStrDict]: def get_include_file_N_condition(_include_file: str) -> tuple[str, str]: _match = re.match( - r"(.*?\.ya?ml)(?: when (['\"]?)(.*)\2)?", + r"(['\"]?.*?\.ya?ml['\"]?)(?: when (['\"]?)(.*)\2)?", _include_file, ) _include_file = _match.groups()[0] @@ -100,7 +100,8 @@ def condition_include( for path in conf_path.parent.glob(include_file) ] for _elem in _res: - _tmp.update(_elem) + for _sub in _elem: + _tmp.update(_sub) if _condition: _condition_short = re.match(r"{{ (.*?) }}", _condition).groups()[0] _tmp.update( @@ -114,7 +115,6 @@ def condition_include( _tmp = condition_questions(_tmp, _condition) return _tmp - # return _res def load_template_config(conf_path: Path, quiet: bool = False) -> AnyByStrDict: diff --git a/tests/test_config.py b/tests/test_config.py index fed0c240b..6ee2c4846 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -737,6 +737,7 @@ def test_secret_question_requires_default_value( ( ("./include_me.yml when {{ var }}", ("./include_me.yml", "{{ var }}")), ("./include_me.yaml", ("./include_me.yaml", None)), + ("'common_*.yml'", ("'common_*.yml'", None)), ), ) def test_get_include_file_N_condition(input: str, output: tuple[str, str | None]): From a14a21fc4ff335331a6bfe56f48264bbf7860584 Mon Sep 17 00:00:00 2001 From: RR5555 Date: Tue, 26 Aug 2025 21:51:08 +0000 Subject: [PATCH 04/11] fix: fix static typing [mypy] --- copier/_template.py | 13 ++++++++----- tests/test_config.py | 8 +++++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/copier/_template.py b/copier/_template.py index 3c6e2bd27..474a77f8b 100644 --- a/copier/_template.py +++ b/copier/_template.py @@ -68,9 +68,11 @@ def get_include_file_N_condition(_include_file: str) -> tuple[str, str]: r"(['\"]?.*?\.ya?ml['\"]?)(?: when (['\"]?)(.*)\2)?", _include_file, ) - _include_file = _match.groups()[0] - _condition = _match.groups()[-1] - return _include_file, _condition + if _match is not None: + _include_file = _match.groups()[0] + _condition = _match.groups()[-1] + return _include_file, _condition + return _include_file, "" def trim_cond(cond: str) -> str: @@ -81,7 +83,7 @@ def condition_questions(_dict: dict[str, Any], _condition: str) -> dict[str, Any _cond_expr = trim_cond(_condition) for _key, _val in _dict.items(): if _key[0] != "_" and isinstance(_val, dict): - jinja2_type_default = f"{ {'str': '', 'int': 0, 'float': 0.0, 'bool': False}.get(_val.get('type'), '') }" + jinja2_type_default = f"{ {'str': '', 'int': 0, 'float': 0.0, 'bool': False}.get(_val.get('type', ''), '') }" _val = _val.update( { "when": f"{{{{ {trim_cond(_val.get('when', 'True'))} if {_cond_expr} else False }}}}", @@ -103,7 +105,8 @@ def condition_include( for _sub in _elem: _tmp.update(_sub) if _condition: - _condition_short = re.match(r"{{ (.*?) }}", _condition).groups()[0] + _match = re.match(r"{{ (.*?) }}", _condition) + _condition_short = _match.groups()[0] if _match is not None else _condition _tmp.update( { "_exclude": [ diff --git a/tests/test_config.py b/tests/test_config.py index 6ee2c4846..c0c524137 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -740,7 +740,9 @@ def test_secret_question_requires_default_value( ("'common_*.yml'", ("'common_*.yml'", None)), ), ) -def test_get_include_file_N_condition(input: str, output: tuple[str, str | None]): +def test_get_include_file_N_condition( + input: str, output: tuple[str, str | None] +) -> None: assert get_include_file_N_condition(input) == output @@ -752,11 +754,11 @@ def test_trim_cond(input: str, output: str) -> None: @pytest.mark.parametrize("_dict, _condition, output", (({}, "", {}),)) def test_condition_questions( _dict: dict[str, Any], _condition: str, output: dict[str, Any] -): +) -> None: assert condition_questions(_dict, _condition) == output -def test_condition_include(): +def test_condition_include() -> None: pass From be1a6b4fb40c3f6b38a18cbca5c7bcde3ff22716 Mon Sep 17 00:00:00 2001 From: RR5555 Date: Tue, 23 Sep 2025 11:24:37 +0000 Subject: [PATCH 05/11] test(demo_transclude_conditional): add more elements to test to the demo --- .../demo/copier.yml | 1 + .../demo/include_me.yml | 21 ++++ .../{% if test_json %}test_json.py{% endif %} | 0 .../{% if test_path %}test_path.py{% endif %} | 0 .../{% if test_yaml %}test_yaml.py{% endif %} | 0 .../demo/{% if var %}var.py{% endif %} | 0 tests/demo_transclude_conditional/desc.md | 110 ++++++++++++++++++ 7 files changed, 132 insertions(+) create mode 100644 tests/demo_transclude_conditional/demo/{% if test_json %}test_json.py{% endif %} create mode 100644 tests/demo_transclude_conditional/demo/{% if test_path %}test_path.py{% endif %} create mode 100644 tests/demo_transclude_conditional/demo/{% if test_yaml %}test_yaml.py{% endif %} create mode 100644 tests/demo_transclude_conditional/demo/{% if var %}var.py{% endif %} create mode 100644 tests/demo_transclude_conditional/desc.md diff --git a/tests/demo_transclude_conditional/demo/copier.yml b/tests/demo_transclude_conditional/demo/copier.yml index 92e21a5f2..2c885d2e3 100644 --- a/tests/demo_transclude_conditional/demo/copier.yml +++ b/tests/demo_transclude_conditional/demo/copier.yml @@ -3,6 +3,7 @@ condition: type: bool default: True + --- !include ./include_me.yml when "{{ condition }}" diff --git a/tests/demo_transclude_conditional/demo/include_me.yml b/tests/demo_transclude_conditional/demo/include_me.yml index 6ded05b5f..35cb64964 100644 --- a/tests/demo_transclude_conditional/demo/include_me.yml +++ b/tests/demo_transclude_conditional/demo/include_me.yml @@ -13,5 +13,26 @@ test: # 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/{% 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/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` From 7d44298f653c693c5d147cd1df85887b249c6dc0 Mon Sep 17 00:00:00 2001 From: RR5555 Date: Tue, 23 Sep 2025 11:26:29 +0000 Subject: [PATCH 06/11] fix(_template): fix conditional transclusion --- copier/_template.py | 271 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 249 insertions(+), 22 deletions(-) diff --git a/copier/_template.py b/copier/_template.py index 474a77f8b..119d9513a 100644 --- a/copier/_template.py +++ b/copier/_template.py @@ -63,39 +63,276 @@ def filter_config(data: AnyByStrDict) -> tuple[AnyByStrDict, AnyByStrDict]: return config_data, questions_data -def get_include_file_N_condition(_include_file: str) -> tuple[str, str]: +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['\"]?)(?: when (['\"]?)(.*)\2)?", + 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()[-1] + _condition = _match.groups()[3] return _include_file, _condition - return _include_file, "" + 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) -> dict[str, Any]: - _cond_expr = trim_cond(_condition) +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"{ {'str': '', 'int': 0, 'float': 0.0, 'bool': False}.get(_val.get('type', ''), '') }" + jinja2_type_default = ( + f"{jinja2_type_default_dict.get(_val.get('type', ''), '')}" + ) _val = _val.update( { - "when": f"{{{{ {trim_cond(_val.get('when', 'True'))} if {_cond_expr} else False }}}}", - "default": f"{{% if {_cond_expr} %}}{_val.get('default', '')}{{% else %}}{jinja2_type_default}{{% endif %}}", + "when": f"{{{{ {trim_cond(_val.get('when', 'True'))} if ({_condition}) else False }}}}", + "default": f"{{% if ({_condition}) %}}{_val.get('default', '')}{{% else %}}{jinja2_type_default}{{% endif %}}", } ) - return _dict + + +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.Loader, include_file: str, _condition: str + 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)))) @@ -105,17 +342,7 @@ def condition_include( for _sub in _elem: _tmp.update(_sub) if _condition: - _match = re.match(r"{{ (.*?) }}", _condition) - _condition_short = _match.groups()[0] if _match is not None else _condition - _tmp.update( - { - "_exclude": [ - f"{{% if {_condition_short} %}}{_elem}{{% endif %}}" - for _elem in _tmp["_exclude"] - ] - } - ) - _tmp = condition_questions(_tmp, _condition) + apply_condition(_tmp, _condition) return _tmp From 8429620f3cc5f20103af4f0b69c08a6ba37b6a36 Mon Sep 17 00:00:00 2001 From: RR5555 Date: Tue, 23 Sep 2025 11:27:43 +0000 Subject: [PATCH 07/11] test(test_config.py): add or fix tests for conditional transclusion functions --- tests/test_config.py | 459 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 445 insertions(+), 14 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index c0c524137..e3bfff382 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,11 +1,13 @@ 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 @@ -15,9 +17,14 @@ 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 @@ -732,40 +739,431 @@ def test_secret_question_requires_default_value( copier.run_copy(str(src), dst) +##### conditional_transclusion ##### + + +@pytest.mark.conditional_transclusion @pytest.mark.parametrize( - "input, output", + "input, output, is_error", ( - ("./include_me.yml when {{ var }}", ("./include_me.yml", "{{ var }}")), - ("./include_me.yaml", ("./include_me.yaml", None)), - ("'common_*.yml'", ("'common_*.yml'", None)), + ("./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, str | None] + input: str, output: tuple[str | None, str | None], is_error: bool ) -> None: - assert get_include_file_N_condition(input) == output + with pytest.raises(ValueError) if is_error else nullcontext(): + assert get_include_file_N_condition(input) == output -@pytest.mark.parametrize("input, output", (("{{ test }}", "test"),)) +@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.parametrize("_dict, _condition, 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: - assert condition_questions(_dict, _condition) == output + 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( + "_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: - pass + 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 }}", + }, + "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", + "inputs, output_files, answer_file, src", ( - # ({"condition": True, "var": None, "the_str": None, "tests": None}), ( { "condition": True, @@ -777,11 +1175,16 @@ def test_condition_include() -> None: ".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, }, - "# Changes here will be overwritten by Copier\n_src_path: tests/demo_transclude_conditional/demo\ncondition: true\ntest: false\nthe_str: my_str\nvar: 1\n", + f"# Changes here will be overwritten by Copier\n_src_path: tests/demo_transclude_conditional/demo\ncondition: true\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", ), ( { @@ -794,11 +1197,38 @@ def test_condition_include() -> None: ".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, }, "# Changes here will be overwritten by Copier\n_src_path: tests/demo_transclude_conditional/demo\ncondition: false\n", + "tests/demo_transclude_conditional/demo", + ), + ( + { + "condition": True, + "var": 0, + "the_str": "my_str", + "test": None, + }, + { + ".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, + }, + f"# Changes here will be overwritten by Copier\n_src_path: tests/demo_transclude_conditional/demo\ncondition: true\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", ), ), ) @@ -808,9 +1238,9 @@ def test_conditional_transclusion( inputs: dict[str, tuple[bool, Any]], output_files: dict[str, bool], answer_file: str, + src: str, ) -> None: dst = tmp_path_factory.mktemp("dst") - src: str = "tests/demo_transclude_conditional/demo" # clear capture output log capsys.readouterr() @@ -823,6 +1253,7 @@ def test_conditional_transclusion( dst, data=_data, quiet=True, + defaults=True, ) ## print("".join(["#"] * 20)) From f8a1eea271534029df2581ad3ebe000b1db20968 Mon Sep 17 00:00:00 2001 From: RR5555 Date: Tue, 23 Sep 2025 13:06:33 +0000 Subject: [PATCH 08/11] test(test_config.py): fix test output to match added vars in demo --- tests/test_config.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_config.py b/tests/test_config.py index e3bfff382..682314b17 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1137,6 +1137,21 @@ class _Loader(yaml.FullLoader): "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", From bba31055d597de3d71056d1969b420ab26cfc4ef Mon Sep 17 00:00:00 2001 From: RR5555 Date: Thu, 25 Sep 2025 00:33:38 +0000 Subject: [PATCH 09/11] test(test_config.py): add use case from issue Refs: 1076#issue-1653172783 --- tests/demo_transclude_conditional/demo/copier.yml | 12 ++++++++++++ tests/demo_transclude_conditional/demo/env/dev.yml | 3 +++ tests/test_config.py | 12 +++++++++--- 3 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 tests/demo_transclude_conditional/demo/env/dev.yml diff --git a/tests/demo_transclude_conditional/demo/copier.yml b/tests/demo_transclude_conditional/demo/copier.yml index 2c885d2e3..caa465762 100644 --- a/tests/demo_transclude_conditional/demo/copier.yml +++ b/tests/demo_transclude_conditional/demo/copier.yml @@ -13,3 +13,15 @@ when "{{ condition }}" _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/test_config.py b/tests/test_config.py index 682314b17..f0e463ef4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1185,6 +1185,7 @@ class _Loader(yaml.FullLoader): "var": 1, "the_str": "my_str", "test": False, + "env_type": "dev", }, { ".copier-answers.yml": True, @@ -1197,8 +1198,9 @@ class _Loader(yaml.FullLoader): "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\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", + 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", ), ( @@ -1207,6 +1209,7 @@ class _Loader(yaml.FullLoader): "var": None, "the_str": None, "test": None, + "env_type": "run", }, { ".copier-answers.yml": True, @@ -1219,8 +1222,9 @@ class _Loader(yaml.FullLoader): "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\n", + "# 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", ), ( @@ -1229,6 +1233,7 @@ class _Loader(yaml.FullLoader): "var": 0, "the_str": "my_str", "test": None, + "env_type": "run", }, { ".copier-answers.yml": True, @@ -1241,8 +1246,9 @@ class _Loader(yaml.FullLoader): "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\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", + 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", ), ), From d352f95d0f29f02ee0eef4671d595de0fe01baff Mon Sep 17 00:00:00 2001 From: RR5555 Date: Thu, 25 Sep 2025 01:12:06 +0000 Subject: [PATCH 10/11] test(pyproject.toml): add marker for `conditional_transclusion` tests [to be removed in prod] --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 = [ From 474ff5b5c755b2b7e850cf4da051c1ad5227158f Mon Sep 17 00:00:00 2001 From: RR5555 Date: Thu, 25 Sep 2025 01:23:35 +0000 Subject: [PATCH 11/11] test(test_config.py): add test for error case Complying to CodeCov --- tests/test_config.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_config.py b/tests/test_config.py index f0e463ef4..18aa3c0dd 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1048,6 +1048,21 @@ 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",