Fix indentation with nested dicts
This commit is contained in:
parent
1413076c9c
commit
77d1658e65
5 changed files with 85 additions and 26 deletions
6
debian/changelog
vendored
6
debian/changelog
vendored
|
|
@ -1,3 +1,9 @@
|
||||||
|
jinjaturtle (0.5.2) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Fix indentation problems with nested dicts
|
||||||
|
|
||||||
|
-- Miguel Jacq <mig@mig5.net> Fri, 19 Jun 2026 18:33:00 +1000
|
||||||
|
|
||||||
jinjaturtle (0.5.1) unstable; urgency=medium
|
jinjaturtle (0.5.1) unstable; urgency=medium
|
||||||
|
|
||||||
* Empty dicts and lists are now emitted as leaf defaults.
|
* Empty dicts and lists are now emitted as leaf defaults.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "jinjaturtle"
|
name = "jinjaturtle"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
description = "Convert config files into Ansible defaults and Jinja2 templates."
|
description = "Convert config files into Ansible defaults and Jinja2 templates."
|
||||||
authors = ["Miguel Jacq <mig@mig5.net>"]
|
authors = ["Miguel Jacq <mig@mig5.net>"]
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
%global upstream_version 0.5.1
|
%global upstream_version 0.5.2
|
||||||
|
|
||||||
Name: jinjaturtle
|
Name: jinjaturtle
|
||||||
Version: %{upstream_version}
|
Version: %{upstream_version}
|
||||||
|
|
@ -43,6 +43,8 @@ Convert config files into Ansible defaults and Jinja2 templates.
|
||||||
|
|
||||||
%changelog
|
%changelog
|
||||||
* Fri Jun 19 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
* Fri Jun 19 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||||
|
- Fix indentation problems with nested dicts
|
||||||
|
* Fri Jun 19 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||||
- Empty dicts and lists are now emitted as leaf defaults.
|
- Empty dicts and lists are now emitted as leaf defaults.
|
||||||
* Tue May 11 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
* Tue May 11 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||||
- Support ssh configs
|
- Support ssh configs
|
||||||
|
|
|
||||||
|
|
@ -208,12 +208,21 @@ class YamlHandler(DictLikeHandler):
|
||||||
stripped = raw_line.lstrip()
|
stripped = raw_line.lstrip()
|
||||||
indent = len(raw_line) - len(stripped)
|
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 skip_until_indent is not None:
|
||||||
if (
|
if not stripped or stripped.startswith("#"):
|
||||||
indent <= skip_until_indent
|
continue
|
||||||
and stripped
|
if indent < skip_until_indent or (
|
||||||
and not stripped.startswith("#")
|
indent == skip_until_indent and not stripped.startswith("- ")
|
||||||
):
|
):
|
||||||
skip_until_indent = None
|
skip_until_indent = None
|
||||||
else:
|
else:
|
||||||
|
|
@ -374,39 +383,36 @@ class YamlHandler(DictLikeHandler):
|
||||||
collection_var = self.make_var_name(role_prefix, candidate.path)
|
collection_var = self.make_var_name(role_prefix, candidate.path)
|
||||||
item_var = candidate.loop_var
|
item_var = candidate.loop_var
|
||||||
|
|
||||||
lines = []
|
lines: list[str] = []
|
||||||
|
|
||||||
if not is_list:
|
if not is_list:
|
||||||
# Dict-style: key: {% for ... %}
|
|
||||||
key = candidate.path[-1] if candidate.path else "items"
|
key = candidate.path[-1] if candidate.path else "items"
|
||||||
lines.append(f"{indent_str}{key}:")
|
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:
|
if candidate.items:
|
||||||
sample_item = candidate.items[0]
|
sample_item = candidate.items[0]
|
||||||
item_indent = indent + 2 if not is_list else indent
|
item_indent = indent + 2 if not is_list else indent
|
||||||
|
item_indent_str = " " * item_indent
|
||||||
|
|
||||||
if candidate.item_schema == "scalar":
|
if candidate.item_schema == "scalar":
|
||||||
# Simple list of scalars
|
item_lines.append(f"{item_indent_str}- {{{{ {item_var} }}}}")
|
||||||
if is_list:
|
|
||||||
lines.append(f"{indent_str}- {{{{ {item_var} }}}}")
|
|
||||||
else:
|
|
||||||
lines.append(f"{indent_str} - {{{{ {item_var} }}}}")
|
|
||||||
|
|
||||||
elif candidate.item_schema in ("simple_dict", "nested"):
|
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(
|
item_lines = self._dict_to_yaml_lines(
|
||||||
sample_item, item_var, item_indent, is_list_item=True
|
sample_item, item_var, item_indent, is_list_item=True
|
||||||
)
|
)
|
||||||
lines.extend(item_lines)
|
|
||||||
|
|
||||||
# Close loop
|
if item_lines:
|
||||||
close_indent = indent + 2 if not is_list else indent
|
# Put the first YAML item on the same physical line as the Jinja
|
||||||
lines.append(f"{' ' * close_indent}{{% endfor %}}")
|
# ``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"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,3 +119,48 @@ def test_yaml_empty_collection_defaults_match_template_vars(tmp_path: Path):
|
||||||
)
|
)
|
||||||
assert "{{ role_ignore }}" in template
|
assert "{{ role_ignore }}" in template
|
||||||
assert "{{ role_settings }}" 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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue