Render json files in a more pretty way
All checks were successful
CI / test (push) Successful in 39s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 17s

This commit is contained in:
Miguel Jacq 2025-12-21 11:16:47 +11:00
parent 953ba11036
commit 3e7d1703b3
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
3 changed files with 104 additions and 41 deletions

6
debian/changelog vendored
View file

@ -1,3 +1,9 @@
jinjaturtle (0.3.4) unstable; urgency=medium
* Render json files in a more pretty way
-- Miguel Jacq <mig@mig5.net> Sun, 21 Dec 2025 11:20:00 +1100
jinjaturtle (0.3.3) unstable; urgency=medium jinjaturtle (0.3.3) unstable; urgency=medium
* Fixes for tomli on Ubuntu 22 * Fixes for tomli on Ubuntu 22

View file

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

@ -29,6 +29,34 @@ class JsonHandler(DictLikeHandler):
# As before: ignore original_text and rebuild structurally # As before: ignore original_text and rebuild structurally
return self._generate_json_template(role_prefix, parsed) return self._generate_json_template(role_prefix, parsed)
JSON_INDENT = 2
def _leading_indent(self, s: str, idx: int) -> int:
"""Return the number of leading spaces on the line containing idx."""
line_start = s.rfind("\n", 0, idx) + 1
indent = 0
while line_start + indent < len(s) and s[line_start + indent] == " ":
indent += 1
return indent
def _replace_marker_with_pretty_loop(
self,
s: str,
marker: str,
replacement_builder,
) -> str:
"""
Replace a quoted marker with an indentation-aware multiline snippet.
`marker` must include the surrounding JSON quotes, e.g. '"__LOOP_SCALAR__...__"'.
"""
marker_re = re.compile(re.escape(marker))
def _repl(m: re.Match[str]) -> str:
base_indent = self._leading_indent(m.string, m.start())
return replacement_builder(base_indent)
return marker_re.sub(_repl, s)
def generate_jinja2_template_with_loops( def generate_jinja2_template_with_loops(
self, self,
parsed: Any, parsed: Any,
@ -127,65 +155,94 @@ class JsonHandler(DictLikeHandler):
r'"__SCALAR__([a-zA-Z_][a-zA-Z0-9_]*)__"', r"{{ \1 | tojson }}", json_str r'"__SCALAR__([a-zA-Z_][a-zA-Z0-9_]*)__"', r"{{ \1 | tojson }}", json_str
) )
# Post-process to replace loop markers with actual Jinja loops # Post-process to replace loop markers with actual Jinja loops (indent-aware)
for candidate in loop_candidates: for candidate in loop_candidates:
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
if candidate.item_schema == "scalar": if candidate.item_schema == "scalar":
# Replace scalar loop marker with Jinja for loop
marker = f'"__LOOP_SCALAR__{collection_var}__{item_var}__"' marker = f'"__LOOP_SCALAR__{collection_var}__{item_var}__"'
replacement = self._generate_json_scalar_loop( json_str = self._replace_marker_with_pretty_loop(
collection_var, item_var, candidate json_str,
marker,
lambda base, cv=collection_var, iv=item_var, c=candidate: self._generate_json_scalar_loop(
cv, iv, c, base
),
) )
json_str = json_str.replace(marker, replacement)
elif candidate.item_schema in ("simple_dict", "nested"): elif candidate.item_schema in ("simple_dict", "nested"):
# Replace dict loop marker with Jinja for loop
marker = f'"__LOOP_DICT__{collection_var}__{item_var}__"' marker = f'"__LOOP_DICT__{collection_var}__{item_var}__"'
replacement = self._generate_json_dict_loop( json_str = self._replace_marker_with_pretty_loop(
collection_var, item_var, candidate json_str,
marker,
lambda base, cv=collection_var, iv=item_var, c=candidate: self._generate_json_dict_loop(
cv, iv, c, base
),
) )
json_str = json_str.replace(marker, replacement)
return json_str + "\n" return json_str + "\n"
def _generate_json_scalar_loop( def _generate_json_scalar_loop(
self, collection_var: str, item_var: str, candidate: LoopCandidate self,
collection_var: str,
item_var: str,
candidate: LoopCandidate,
base_indent: int,
) -> str: ) -> str:
"""Generate a Jinja for loop for a scalar list in JSON.""" """Generate an indentation-preserving Jinja for-loop for a scalar JSON list."""
# Use tojson filter to properly handle strings (quotes them) and other types
# Include array brackets around the loop
return (
f"[{{% for {item_var} in {collection_var} %}}"
f"{{{{ {item_var} | tojson }}}}"
f"{{% if not loop.last %}}, {{% endif %}}"
f"{{% endfor %}}]"
)
def _generate_json_dict_loop(
self, collection_var: str, item_var: str, candidate: LoopCandidate
) -> str:
"""Generate a Jinja for loop for a dict list in JSON."""
if not candidate.items: if not candidate.items:
return "[]" return "[]"
# Get first item as template inner_indent = base_indent + self.JSON_INDENT
sample_item = candidate.items[0] inner = " " * inner_indent
base = " " * base_indent
# Build the dict template - use tojson for all values to handle types correctly
fields = []
for key, value in sample_item.items():
if key == "_key":
continue
# Use tojson filter to properly serialize all types (strings, numbers, booleans)
fields.append(f'"{key}": {{{{ {item_var}.{key} | tojson }}}}')
dict_template = "{" + ", ".join(fields) + "}"
# Put the `{% for %}` at the start of the first item line so we don't emit
# a blank line between iterations under default Jinja whitespace settings.
return ( return (
f"{{% for {item_var} in {collection_var} %}}" f"[\n"
f"{dict_template}" f"{{% for {item_var} in {collection_var} %}}{inner}{{{{ {item_var} | tojson }}}}"
f"{{% if not loop.last %}}, {{% endif %}}" f"{{% if not loop.last %}},{{% endif %}}\n"
f"{{% endfor %}}" f"{{% endfor %}}{base}]"
)
def _generate_json_dict_loop(
self,
collection_var: str,
item_var: str,
candidate: LoopCandidate,
base_indent: int,
) -> str:
"""Generate an indentation-preserving Jinja for-loop for a list of dicts in JSON."""
if not candidate.items:
return "[]"
# Get first item as template (preserve key order from the sample)
sample_item = candidate.items[0]
keys = [k for k in sample_item.keys() if k != "_key"]
inner_indent = base_indent + self.JSON_INDENT # list item indent
field_indent = inner_indent + self.JSON_INDENT # dict field indent
inner = " " * inner_indent
field = " " * field_indent
base = " " * base_indent
# Build a pretty dict body that matches json.dumps(indent=2) style.
dict_lines: list[str] = [
"{"
] # first line has no indent; we prepend `inner` when emitting
for i, key in enumerate(keys):
comma = "," if i < len(keys) - 1 else ""
dict_lines.append(
f'{field}"{key}": {{{{ {item_var}.{key} | tojson }}}}{comma}'
)
# Comma between *items* goes after the closing brace.
dict_lines.append(f"{inner}}}{{% if not loop.last %}},{{% endif %}}")
dict_body = "\n".join(dict_lines)
# Put the `{% for %}` at the start of the first item line to avoid blank lines.
return (
f"[\n"
f"{{% for {item_var} in {collection_var} %}}{inner}{dict_body}\n"
f"{{% endfor %}}{base}]"
) )