Fix loss of comments and True/False to true/false
All checks were successful
CI / test (push) Successful in 1m5s
Lint / test (push) Successful in 36s

This commit is contained in:
Miguel Jacq 2026-06-19 18:46:04 +10:00
parent 77d1658e65
commit 3d53d4fb30
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
5 changed files with 114 additions and 9 deletions

6
debian/changelog vendored
View file

@ -1,3 +1,9 @@
jinjaturtle (0.5.3) unstable; urgency=medium
* Fix loss of comments and True/False to true/false
-- Miguel Jacq <mig@mig5.net> Fri, 19 Jun 2026 18:43:00 +1000
jinjaturtle (0.5.2) unstable; urgency=medium jinjaturtle (0.5.2) unstable; urgency=medium
* Fix indentation problems with nested dicts * Fix indentation problems with nested dicts

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "jinjaturtle" name = "jinjaturtle"
version = "0.5.2" version = "0.5.3"
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"

View file

@ -1,4 +1,4 @@
%global upstream_version 0.5.2 %global upstream_version 0.5.3
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 loss of comments and True/False to true/false
* Fri Jun 19 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Fix indentation problems with nested dicts - Fix indentation problems with nested dicts
* Fri Jun 19 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release} * 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.

View file

@ -59,6 +59,28 @@ class YamlHandler(DictLikeHandler):
role_prefix, dumped, loop_candidates, loop_paths role_prefix, dumped, loop_candidates, loop_paths
) )
def _yaml_scalar_expr(self, var_name: str, raw_value: str | None = None) -> str:
"""Return a Jinja expression that preserves YAML scalar spelling.
Plain ``{{ var }}`` renders Python booleans as ``True``/``False``.
YAML config files conventionally use ``true``/``false`` and some
consumers are stricter than PyYAML, so emit explicit YAML spelling for
values that were originally YAML booleans/nulls.
"""
raw = (raw_value or "").strip().lower()
if raw in {"true", "false"}:
return f"{{{{ 'true' if {var_name} else 'false' }}}}"
if raw in {"null", "~"}:
return f"{{{{ 'null' if {var_name} is none else {var_name} }}}}"
return f"{{{{ {var_name} }}}}"
def _yaml_value_expr(self, value_expr: str, sample_value: Any | None = None) -> str:
if isinstance(sample_value, bool):
return f"{{{{ 'true' if {value_expr} else 'false' }}}}"
if sample_value is None:
return f"{{{{ 'null' if {value_expr} is none else {value_expr} }}}}"
return f"{{{{ {value_expr} }}}}"
def _generate_yaml_template_from_text( def _generate_yaml_template_from_text(
self, self,
role_prefix: str, role_prefix: str,
@ -121,7 +143,7 @@ class YamlHandler(DictLikeHandler):
q = raw_value[0] q = raw_value[0]
replacement = f"{q}{{{{ {var_name} }}}}{q}" replacement = f"{q}{{{{ {var_name} }}}}{q}"
else: else:
replacement = f"{{{{ {var_name} }}}}" replacement = self._yaml_scalar_expr(var_name, raw_value)
leading = rest[: len(rest) - len(rest.lstrip(" \t"))] leading = rest[: len(rest) - len(rest.lstrip(" \t"))]
new_rest = f"{leading}{replacement}{comment_part}" new_rest = f"{leading}{replacement}{comment_part}"
@ -160,7 +182,7 @@ class YamlHandler(DictLikeHandler):
q = raw_value[0] q = raw_value[0]
replacement = f"{q}{{{{ {var_name} }}}}{q}" replacement = f"{q}{{{{ {var_name} }}}}{q}"
else: else:
replacement = f"{{{{ {var_name} }}}}" replacement = self._yaml_scalar_expr(var_name, raw_value)
new_stripped = f"- {replacement}{comment_part}" new_stripped = f"- {replacement}{comment_part}"
out_lines.append( out_lines.append(
@ -220,6 +242,12 @@ class YamlHandler(DictLikeHandler):
# or when indentation moves above the parent collection. # or when indentation moves above the parent collection.
if skip_until_indent is not None: if skip_until_indent is not None:
if not stripped or stripped.startswith("#"): if not stripped or stripped.startswith("#"):
if indent <= skip_until_indent:
skip_until_indent = None
out_lines.append(raw_line)
# Comments/blank lines indented beneath the replaced
# collection are considered part of that collection and
# cannot be placed safely inside a generated loop.
continue continue
if indent < skip_until_indent or ( if indent < skip_until_indent or (
indent == skip_until_indent and not stripped.startswith("- ") indent == skip_until_indent and not stripped.startswith("- ")
@ -288,7 +316,7 @@ class YamlHandler(DictLikeHandler):
q = raw_value[0] q = raw_value[0]
replacement = f"{q}{{{{ {var_name} }}}}{q}" replacement = f"{q}{{{{ {var_name} }}}}{q}"
else: else:
replacement = f"{{{{ {var_name} }}}}" replacement = self._yaml_scalar_expr(var_name, raw_value)
leading = rest[: len(rest) - len(rest.lstrip(" \t"))] leading = rest[: len(rest) - len(rest.lstrip(" \t"))]
new_rest = f"{leading}{replacement}{comment_part}" new_rest = f"{leading}{replacement}{comment_part}"
@ -345,7 +373,7 @@ class YamlHandler(DictLikeHandler):
q = raw_value[0] q = raw_value[0]
replacement = f"{q}{{{{ {var_name} }}}}{q}" replacement = f"{q}{{{{ {var_name} }}}}{q}"
else: else:
replacement = f"{{{{ {var_name} }}}}" replacement = self._yaml_scalar_expr(var_name, raw_value)
new_stripped = f"- {replacement}{comment_part}" new_stripped = f"- {replacement}{comment_part}"
out_lines.append( out_lines.append(
@ -395,7 +423,9 @@ class YamlHandler(DictLikeHandler):
item_indent_str = " " * item_indent item_indent_str = " " * item_indent
if candidate.item_schema == "scalar": if candidate.item_schema == "scalar":
item_lines.append(f"{item_indent_str}- {{{{ {item_var} }}}}") item_lines.append(
f"{item_indent_str}- {self._yaml_value_expr(item_var, sample_item)}"
)
elif candidate.item_schema in ("simple_dict", "nested"): elif candidate.item_schema in ("simple_dict", "nested"):
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
@ -447,11 +477,13 @@ class YamlHandler(DictLikeHandler):
if first_key and is_list_item: if first_key and is_list_item:
# First key gets the list marker # First key gets the list marker
lines.append(f"{indent_str}- {key}: {{{{ {loop_var}.{key} }}}}") value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value)
lines.append(f"{indent_str}- {key}: {value_expr}")
first_key = False first_key = False
else: else:
# Subsequent keys are indented # Subsequent keys are indented
sub_indent = indent + 2 if is_list_item else indent sub_indent = indent + 2 if is_list_item else indent
lines.append(f"{' ' * sub_indent}{key}: {{{{ {loop_var}.{key} }}}}") value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value)
lines.append(f"{' ' * sub_indent}{key}: {value_expr}")
return lines return lines

View file

@ -164,3 +164,68 @@ def test_yaml_indentless_sequence_loop_roundtrips_semantically(tmp_path: Path):
assert yaml.safe_load(rendered) == yaml.safe_load(text) assert yaml.safe_load(rendered) == yaml.safe_load(text)
assert " - waffleimage/ubuntu18.04" not in template assert " - waffleimage/ubuntu18.04" not in template
assert " vagrant:" not in template assert " vagrant:" not in template
def test_yaml_loop_preserves_following_top_level_comments(tmp_path: Path):
from jinja2 import Template
from jinjaturtle.core import analyze_loops
text = textwrap.dedent(
"""
Style/HashExcept:
Exclude:
- lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb
- spec/unit/puppet/provider/dsc_base_provider/dsc_base_provider_spec.rb
# Offense count: 2
# This cop supports unsafe autocorrection (--autocorrect-all).
Style/MapIntoArray:
Exclude:
- lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb
"""
).lstrip()
path = tmp_path / ".rubocop_todo.yml"
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 "# Offense count: 2" in rendered
assert "# This cop supports unsafe autocorrection" in rendered
assert yaml.safe_load(rendered) == yaml.safe_load(text)
def test_yaml_bool_scalars_render_with_yaml_spelling(tmp_path: Path):
from jinja2 import Template
text = textwrap.dedent(
"""
AllCops:
SuggestExtensions: false
Style/ClassAndModuleChildren:
Enabled: false
"""
).lstrip()
path = tmp_path / ".rubocop.yml"
path.write_text(text, encoding="utf-8")
fmt, parsed = parse_config(path)
flat_items = flatten_config(fmt, parsed)
defaults = yaml.safe_load(generate_ansible_yaml("role", flat_items))
template = generate_jinja2_template(fmt, parsed, "role", original_text=text)
rendered = Template(template).render(**defaults)
assert "SuggestExtensions: false" in rendered
assert "Enabled: false" in rendered
assert "SuggestExtensions: False" not in rendered
assert "Enabled: False" not in rendered
assert yaml.safe_load(rendered) == yaml.safe_load(text)