Fix loss of comments and True/False to true/false
This commit is contained in:
parent
77d1658e65
commit
3d53d4fb30
5 changed files with 114 additions and 9 deletions
6
debian/changelog
vendored
6
debian/changelog
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue