From 3d53d4fb3027dc154a5ede58cf5a5f5578a0923c Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 19 Jun 2026 18:46:04 +1000 Subject: [PATCH] Fix loss of comments and True/False to true/false --- debian/changelog | 6 +++ pyproject.toml | 2 +- rpm/jinjaturtle.spec | 4 +- src/jinjaturtle/handlers/yaml.py | 46 ++++++++++++++++++---- tests/test_yaml_handler.py | 65 ++++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 9 deletions(-) diff --git a/debian/changelog b/debian/changelog index 3b866b0..5ab9ef4 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +jinjaturtle (0.5.3) unstable; urgency=medium + + * Fix loss of comments and True/False to true/false + + -- Miguel Jacq Fri, 19 Jun 2026 18:43:00 +1000 + jinjaturtle (0.5.2) unstable; urgency=medium * Fix indentation problems with nested dicts diff --git a/pyproject.toml b/pyproject.toml index 11f17ce..039c55d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "jinjaturtle" -version = "0.5.2" +version = "0.5.3" 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 2b26be0..8d0da22 100644 --- a/rpm/jinjaturtle.spec +++ b/rpm/jinjaturtle.spec @@ -1,4 +1,4 @@ -%global upstream_version 0.5.2 +%global upstream_version 0.5.3 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 loss of comments and True/False to true/false +* 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. diff --git a/src/jinjaturtle/handlers/yaml.py b/src/jinjaturtle/handlers/yaml.py index c66cf9a..be1bd8d 100644 --- a/src/jinjaturtle/handlers/yaml.py +++ b/src/jinjaturtle/handlers/yaml.py @@ -59,6 +59,28 @@ class YamlHandler(DictLikeHandler): 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( self, role_prefix: str, @@ -121,7 +143,7 @@ class YamlHandler(DictLikeHandler): q = raw_value[0] replacement = f"{q}{{{{ {var_name} }}}}{q}" else: - replacement = f"{{{{ {var_name} }}}}" + replacement = self._yaml_scalar_expr(var_name, raw_value) leading = rest[: len(rest) - len(rest.lstrip(" \t"))] new_rest = f"{leading}{replacement}{comment_part}" @@ -160,7 +182,7 @@ class YamlHandler(DictLikeHandler): q = raw_value[0] replacement = f"{q}{{{{ {var_name} }}}}{q}" else: - replacement = f"{{{{ {var_name} }}}}" + replacement = self._yaml_scalar_expr(var_name, raw_value) new_stripped = f"- {replacement}{comment_part}" out_lines.append( @@ -220,6 +242,12 @@ class YamlHandler(DictLikeHandler): # or when indentation moves above the parent collection. if skip_until_indent is not None: 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 if indent < skip_until_indent or ( indent == skip_until_indent and not stripped.startswith("- ") @@ -288,7 +316,7 @@ class YamlHandler(DictLikeHandler): q = raw_value[0] replacement = f"{q}{{{{ {var_name} }}}}{q}" else: - replacement = f"{{{{ {var_name} }}}}" + replacement = self._yaml_scalar_expr(var_name, raw_value) leading = rest[: len(rest) - len(rest.lstrip(" \t"))] new_rest = f"{leading}{replacement}{comment_part}" @@ -345,7 +373,7 @@ class YamlHandler(DictLikeHandler): q = raw_value[0] replacement = f"{q}{{{{ {var_name} }}}}{q}" else: - replacement = f"{{{{ {var_name} }}}}" + replacement = self._yaml_scalar_expr(var_name, raw_value) new_stripped = f"- {replacement}{comment_part}" out_lines.append( @@ -395,7 +423,9 @@ class YamlHandler(DictLikeHandler): item_indent_str = " " * item_indent 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"): item_lines = self._dict_to_yaml_lines( sample_item, item_var, item_indent, is_list_item=True @@ -447,11 +477,13 @@ class YamlHandler(DictLikeHandler): if first_key and is_list_item: # 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 else: # Subsequent keys are indented 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 diff --git a/tests/test_yaml_handler.py b/tests/test_yaml_handler.py index eb740a9..41edc07 100644 --- a/tests/test_yaml_handler.py +++ b/tests/test_yaml_handler.py @@ -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 " - waffleimage/ubuntu18.04" 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)