From 77d1658e65540fa1ebcf0e2e8aae3403b9c2288f Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 19 Jun 2026 18:38:35 +1000 Subject: [PATCH] Fix indentation with nested dicts --- debian/changelog | 6 ++++ pyproject.toml | 2 +- rpm/jinjaturtle.spec | 4 ++- src/jinjaturtle/handlers/yaml.py | 54 ++++++++++++++++++-------------- tests/test_yaml_handler.py | 45 ++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 26 deletions(-) diff --git a/debian/changelog b/debian/changelog index 25414d1..3b866b0 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +jinjaturtle (0.5.2) unstable; urgency=medium + + * Fix indentation problems with nested dicts + + -- Miguel Jacq Fri, 19 Jun 2026 18:33:00 +1000 + jinjaturtle (0.5.1) unstable; urgency=medium * Empty dicts and lists are now emitted as leaf defaults. diff --git a/pyproject.toml b/pyproject.toml index 1e80645..11f17ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "jinjaturtle" -version = "0.5.1" +version = "0.5.2" description = "Convert config files into Ansible defaults and Jinja2 templates." authors = ["Miguel Jacq "] license = "GPL-3.0-or-later" diff --git a/rpm/jinjaturtle.spec b/rpm/jinjaturtle.spec index ee4305f..2b26be0 100644 --- a/rpm/jinjaturtle.spec +++ b/rpm/jinjaturtle.spec @@ -1,4 +1,4 @@ -%global upstream_version 0.5.1 +%global upstream_version 0.5.2 Name: jinjaturtle Version: %{upstream_version} @@ -43,6 +43,8 @@ Convert config files into Ansible defaults and Jinja2 templates. %changelog * Fri Jun 19 2026 Miguel Jacq - %{version}-%{release} +- Fix indentation problems with nested dicts +* Fri Jun 19 2026 Miguel Jacq - %{version}-%{release} - Empty dicts and lists are now emitted as leaf defaults. * Tue May 11 2026 Miguel Jacq - %{version}-%{release} - Support ssh configs diff --git a/src/jinjaturtle/handlers/yaml.py b/src/jinjaturtle/handlers/yaml.py index f75ef4b..c66cf9a 100644 --- a/src/jinjaturtle/handlers/yaml.py +++ b/src/jinjaturtle/handlers/yaml.py @@ -208,12 +208,21 @@ class YamlHandler(DictLikeHandler): stripped = raw_line.lstrip() indent = len(raw_line) - len(stripped) - # If we're skipping lines (inside a loop section), check if we can stop + # If we're skipping lines inside a collection replaced by a loop, + # continue through YAML's indentless sequence style too, where list + # items can appear at the same indentation as the parent key: + # + # images: + # - ubuntu + # - debian + # + # Stop only when a non-list item at the parent indentation appears, + # or when indentation moves above the parent collection. if skip_until_indent is not None: - if ( - indent <= skip_until_indent - and stripped - and not stripped.startswith("#") + if not stripped or stripped.startswith("#"): + continue + if indent < skip_until_indent or ( + indent == skip_until_indent and not stripped.startswith("- ") ): skip_until_indent = None else: @@ -374,39 +383,36 @@ class YamlHandler(DictLikeHandler): collection_var = self.make_var_name(role_prefix, candidate.path) item_var = candidate.loop_var - lines = [] - + lines: list[str] = [] if not is_list: - # Dict-style: key: {% for ... %} key = candidate.path[-1] if candidate.path else "items" lines.append(f"{indent_str}{key}:") - lines.append(f"{indent_str} {{% for {item_var} in {collection_var} -%}}") - else: - # List-style: just the loop - lines.append(f"{indent_str}{{% for {item_var} in {collection_var} -%}}") - # Generate template for item structure + item_lines: list[str] = [] if candidate.items: sample_item = candidate.items[0] item_indent = indent + 2 if not is_list else indent + item_indent_str = " " * item_indent if candidate.item_schema == "scalar": - # Simple list of scalars - if is_list: - lines.append(f"{indent_str}- {{{{ {item_var} }}}}") - else: - lines.append(f"{indent_str} - {{{{ {item_var} }}}}") - + item_lines.append(f"{item_indent_str}- {{{{ {item_var} }}}}") elif candidate.item_schema in ("simple_dict", "nested"): - # List of dicts or complex items - these are ALWAYS list items in YAML item_lines = self._dict_to_yaml_lines( sample_item, item_var, item_indent, is_list_item=True ) - lines.extend(item_lines) - # Close loop - close_indent = indent + 2 if not is_list else indent - lines.append(f"{' ' * close_indent}{{% endfor %}}") + if item_lines: + # Put the first YAML item on the same physical line as the Jinja + # ``for`` tag. With default Jinja whitespace settings this avoids + # rendering a blank line after the parent key. Keeping the control + # tag itself at column zero prevents its indentation from leaking + # into the rendered YAML and nesting the next top-level key. + lines.append(f"{{% for {item_var} in {collection_var} %}}{item_lines[0]}") + lines.extend(item_lines[1:]) + lines.append("{% endfor %}") + else: + lines.append(f"{{% for {item_var} in {collection_var} %}}") + lines.append("{% endfor %}") return "\n".join(lines) + "\n" diff --git a/tests/test_yaml_handler.py b/tests/test_yaml_handler.py index fb4a637..eb740a9 100644 --- a/tests/test_yaml_handler.py +++ b/tests/test_yaml_handler.py @@ -119,3 +119,48 @@ def test_yaml_empty_collection_defaults_match_template_vars(tmp_path: Path): ) assert "{{ role_ignore }}" in template assert "{{ role_settings }}" in template + + +def test_yaml_indentless_sequence_loop_roundtrips_semantically(tmp_path: Path): + """Indentless YAML sequences under a mapping key must be fully replaced. + + A previous loop renderer emitted a loop at ``images:`` but then processed + the original ``- item`` lines again because they had the same indentation + as the parent key. That duplicated values and nested following top-level + keys incorrectly. + """ + from jinja2 import Template + + from jinjaturtle.core import analyze_loops + + text = textwrap.dedent( + """ + default: + provisioner: docker + images: + - waffleimage/ubuntu18.04 + - waffleimage/centos7 + vagrant: + provisioner: vagrant + images: + - centos/7 + - generic/ubuntu1804 + """ + ).lstrip() + path = tmp_path / "provision.yaml" + path.write_text(text, encoding="utf-8") + + fmt, parsed = parse_config(path) + loop_candidates = analyze_loops(fmt, parsed) + flat_items = flatten_config(fmt, parsed, loop_candidates) + defaults = yaml.safe_load( + generate_ansible_yaml("role", flat_items, loop_candidates) + ) + template = generate_jinja2_template( + fmt, parsed, "role", original_text=text, loop_candidates=loop_candidates + ) + rendered = Template(template).render(**defaults) + + assert yaml.safe_load(rendered) == yaml.safe_load(text) + assert " - waffleimage/ubuntu18.04" not in template + assert " vagrant:" not in template