Add erb support

This commit is contained in:
Miguel Jacq 2026-06-20 17:40:30 +10:00
parent 5ee084d395
commit 17612c9883
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
14 changed files with 661 additions and 141 deletions

View file

@ -5,12 +5,15 @@ import sys
from defusedxml import defuse_stdlib from defusedxml import defuse_stdlib
from pathlib import Path from pathlib import Path
from . import j2
from .core import ( from .core import (
parse_config, parse_config,
analyze_loops, analyze_loops,
flatten_config, flatten_config,
generate_ansible_yaml, generate_ansible_yaml,
generate_jinja2_template, generate_jinja2_template,
generate_puppet_hiera_yaml,
generate_erb_template,
) )
from .multi import process_directory from .multi import process_directory
@ -53,7 +56,21 @@ def _build_arg_parser() -> argparse.ArgumentParser:
ap.add_argument( ap.add_argument(
"-t", "-t",
"--template-output", "--template-output",
help="Path to write the Jinja2 config template. If omitted, template is printed to stdout.", help="Path to write the generated config template. If omitted, template is printed to stdout.",
)
ap.add_argument(
"--template-engine",
choices=[j2.NAME, "erb"],
default=j2.NAME,
help="Template syntax to generate (default: jinja2). Use erb for Puppet templates.",
)
ap.add_argument(
"--puppet-class",
help=(
"Puppet class/Hiera namespace to use with --template-engine erb. "
"Defaults to --role-name. This lets tools use a file-specific "
"variable prefix while writing Hiera keys under the real Puppet class."
),
) )
return ap return ap
@ -78,6 +95,21 @@ def _main(argv: list[str] | None = None) -> int:
print("# defaults/main.yml") print("# defaults/main.yml")
print(defaults_yaml, end="") print(defaults_yaml, end="")
# Optionally translate folder-mode templates to ERB. Folder mode keeps
# the existing data shape; single-file mode below is the preferred
# Puppet path because it can produce class-parameter Hiera keys.
if args.template_engine == "erb":
from .erb import translate_jinja2_to_erb
for o in outputs:
o.template = translate_jinja2_to_erb(
o.template,
role_prefix=args.role_name,
puppet_class=args.puppet_class or args.role_name,
)
template_ext = "erb" if args.template_engine == "erb" else j2.TEMPLATE_EXTENSION
# Write templates # Write templates
if args.template_output: if args.template_output:
out_path = Path(args.template_output) out_path = Path(args.template_output)
@ -86,12 +118,16 @@ def _main(argv: list[str] | None = None) -> int:
else: else:
out_path.mkdir(parents=True, exist_ok=True) out_path.mkdir(parents=True, exist_ok=True)
for o in outputs: for o in outputs:
(out_path / f"config.{o.fmt}.j2").write_text( (out_path / f"config.{o.fmt}.{template_ext}").write_text(
o.template, encoding="utf-8" o.template, encoding="utf-8"
) )
else: else:
for o in outputs: for o in outputs:
name = "config.j2" if len(outputs) == 1 else f"config.{o.fmt}.j2" name = (
f"config.{template_ext}"
if len(outputs) == 1
else f"config.{o.fmt}.{template_ext}"
)
print(f"# {name}") print(f"# {name}")
print(o.template, end="") print(o.template, end="")
@ -109,17 +145,36 @@ def _main(argv: list[str] | None = None) -> int:
# Flatten config (excluding loop paths if loops are detected) # Flatten config (excluding loop paths if loops are detected)
flat_items = flatten_config(fmt, parsed, loop_candidates) flat_items = flatten_config(fmt, parsed, loop_candidates)
# Generate defaults YAML (with loop collections if detected) if args.template_engine == "erb":
ansible_yaml = generate_ansible_yaml(args.role_name, flat_items, loop_candidates) ansible_yaml = generate_puppet_hiera_yaml(
args.role_name,
flat_items,
loop_candidates,
puppet_class=args.puppet_class or args.role_name,
)
template_str = generate_erb_template(
fmt,
parsed,
args.role_name,
original_text=config_text,
loop_candidates=loop_candidates,
flat_items=flat_items,
puppet_class=args.puppet_class or args.role_name,
)
else:
# Generate defaults YAML (with loop collections if detected)
ansible_yaml = generate_ansible_yaml(
args.role_name, flat_items, loop_candidates
)
# Generate template (with loops if detected) # Generate template (with loops if detected)
template_str = generate_jinja2_template( template_str = generate_jinja2_template(
fmt, fmt,
parsed, parsed,
args.role_name, args.role_name,
original_text=config_text, original_text=config_text,
loop_candidates=loop_candidates, loop_candidates=loop_candidates,
) )
if args.defaults_output: if args.defaults_output:
Path(args.defaults_output).write_text(ansible_yaml, encoding="utf-8") Path(args.defaults_output).write_text(ansible_yaml, encoding="utf-8")
@ -130,7 +185,11 @@ def _main(argv: list[str] | None = None) -> int:
if args.template_output: if args.template_output:
Path(args.template_output).write_text(template_str, encoding="utf-8") Path(args.template_output).write_text(template_str, encoding="utf-8")
else: else:
print("# config.j2") print(
"# config.erb"
if args.template_engine == "erb"
else f"# config.{j2.TEMPLATE_EXTENSION}"
)
print(template_str, end="") print(template_str, end="")
return 0 return 0

View file

@ -8,6 +8,7 @@ import re
import yaml import yaml
from .loop_analyzer import LoopAnalyzer, LoopCandidate from .loop_analyzer import LoopAnalyzer, LoopCandidate
from .erb import puppet_class_name, puppet_local_var_name, translate_jinja2_to_erb
from .handlers import ( from .handlers import (
BaseHandler, BaseHandler,
IniHandler, IniHandler,
@ -387,6 +388,85 @@ def generate_jinja2_template(
) )
def _template_variable_names(
role_prefix: str,
flat_items: list[tuple[tuple[str, ...], Any]],
loop_candidates: list[LoopCandidate] | None = None,
) -> set[str]:
names = {make_var_name(role_prefix, path) for path, _value in flat_items}
if loop_candidates:
for candidate in loop_candidates:
names.add(make_var_name(role_prefix, candidate.path))
return names
def generate_puppet_hiera_yaml(
role_prefix: str,
flat_items: list[tuple[tuple[str, ...], Any]],
loop_candidates: list[LoopCandidate] | None = None,
*,
puppet_class: str | None = None,
) -> str:
"""Create Puppet Hiera data suitable for Automatic Parameter Lookup.
``role_prefix`` remains the source variable prefix used by JinjaTurtle while
``puppet_class`` is the Puppet class/Hiera namespace. In the normal case
they are the same, so ``php_memory_limit`` becomes ``php::memory_limit``.
Enroll may pass a file-specific role prefix and a separate Puppet class to
avoid parameter-name collisions inside one generated Puppet module.
"""
klass = puppet_class_name(puppet_class or role_prefix)
data: dict[str, Any] = {}
for path, value in flat_items:
generated = make_var_name(role_prefix, path)
local = puppet_local_var_name(role_prefix, generated, puppet_class=klass)
data[f"{klass}::{local}"] = value
if loop_candidates:
for candidate in loop_candidates:
generated = make_var_name(role_prefix, candidate.path)
local = puppet_local_var_name(role_prefix, generated, puppet_class=klass)
data[f"{klass}::{local}"] = candidate.items
return dump_yaml(data, sort_keys=True)
def generate_erb_template(
fmt: str,
parsed: Any,
role_prefix: str,
*,
original_text: str | None = None,
loop_candidates: list[LoopCandidate] | None = None,
flat_items: list[tuple[tuple[str, ...], Any]] | None = None,
puppet_class: str | None = None,
) -> str:
"""Generate a Puppet ERB template from JinjaTurtle's renderer-neutral data.
The first implementation intentionally translates the Jinja2 subset emitted
by JinjaTurtle's existing format handlers. This keeps parsing, formatting
preservation, and loop detection identical between Jinja2 and ERB output
while still producing Puppet-native ``@parameter`` references.
"""
jinja_template = generate_jinja2_template(
fmt,
parsed,
role_prefix,
original_text=original_text,
loop_candidates=loop_candidates,
)
names = _template_variable_names(role_prefix, flat_items or [], loop_candidates)
return translate_jinja2_to_erb(
jinja_template,
role_prefix=role_prefix,
puppet_class=puppet_class or role_prefix,
variable_names=names,
)
def _stringify_timestamps(obj: Any) -> Any: def _stringify_timestamps(obj: Any) -> Any:
""" """
Recursively walk a parsed config and turn any datetime/date/time objects Recursively walk a parsed config and turn any datetime/date/time objects

215
src/jinjaturtle/erb.py Normal file
View file

@ -0,0 +1,215 @@
from __future__ import annotations
import re
def _safe_name(raw: str, *, fallback: str = "var") -> str:
text = re.sub(r"[^A-Za-z0-9_]+", "_", str(raw or fallback)).strip("_").lower()
text = re.sub(r"_+", "_", text)
if not text:
text = fallback
if not re.match(r"^[a-z_]", text):
text = f"{fallback}_{text}"
return text
def puppet_class_name(raw: str) -> str:
"""Return a conservative Puppet class/Hiera namespace name."""
text = _safe_name(raw, fallback="jinjaturtle")
if not re.match(r"^[a-z]", text):
text = f"jinjaturtle_{text}"
return text
def _role_prefix_name(raw: str) -> str:
return _safe_name(raw, fallback="jinjaturtle")
def puppet_local_var_name(
role_prefix: str,
jinja_var_name: str,
*,
puppet_class: str | None = None,
) -> str:
"""Map a generated JinjaTurtle variable to a Puppet class parameter.
For the common case where ``--role-name php`` also means class ``php``, a
generated Jinja variable such as ``php_memory_limit`` becomes Puppet local
parameter ``memory_limit`` and Hiera key ``php::memory_limit``.
Enroll sometimes needs a file-specific variable prefix to avoid collisions
inside a generated module. When ``puppet_class`` differs from
``role_prefix`` we keep the full generated variable name as the local
parameter and only use ``puppet_class`` as the Hiera namespace.
"""
var_name = _safe_name(jinja_var_name, fallback="value")
prefix = _role_prefix_name(role_prefix)
klass = puppet_class_name(puppet_class or role_prefix)
if klass == prefix and var_name.startswith(prefix + "_"):
stripped = var_name[len(prefix) + 1 :]
return stripped or var_name
return var_name
class ErbTranslator:
"""Translate the Jinja2 subset emitted by JinjaTurtle into Puppet ERB."""
_TOKEN_RE = re.compile(r"({{.*?}}|{%.*?%})", re.S)
def __init__(
self,
*,
role_prefix: str,
puppet_class: str | None = None,
variable_names: set[str] | None = None,
) -> None:
self.role_prefix = role_prefix
self.puppet_class = puppet_class or role_prefix
self.variable_names = set(variable_names or set())
self.loop_stack: list[tuple[str, str, str]] = []
self.needs_json = False
def translate(self, template_text: str) -> str:
parts = self._TOKEN_RE.split(template_text)
out: list[str] = []
for token in parts:
if not token:
continue
if token.startswith("{{") and token.endswith("}}"):
expr = token[2:-2].strip()
out.append(f"<%= {self.expr_to_ruby(expr)} %>")
continue
if token.startswith("{%") and token.endswith("%}"):
stmt = token[2:-2].strip()
out.append(self.statement_to_erb(stmt))
continue
out.append(token)
rendered = "".join(out)
if self.needs_json and "require 'json'" not in rendered:
rendered = "<% require 'json' -%>\n" + rendered
return rendered
def local_var(self, name: str) -> str:
return puppet_local_var_name(
self.role_prefix, name, puppet_class=self.puppet_class
)
def ruby_value(self, expr: str) -> str:
expr = expr.strip()
if expr in {"true", "True"}:
return "true"
if expr in {"false", "False"}:
return "false"
if expr in {"none", "None", "null"}:
return "nil"
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", expr):
if any(expr == loop_var for loop_var, _idx, _coll in self.loop_stack):
return expr
return f"@{self.local_var(expr)}"
m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$", expr)
if m:
base, key = m.groups()
if any(base == loop_var for loop_var, _idx, _coll in self.loop_stack):
return f"{base}[{key!r}]"
return f"@{self.local_var(base)}[{key!r}]"
return expr
def expr_to_ruby(self, expr: str) -> str:
expr = expr.strip()
# JinjaTurtle emits these YAML-preserving ternaries for booleans/nulls.
m = re.match(
r"^(['\"])(true|false)\1\s+if\s+([A-Za-z_][A-Za-z0-9_\.]*)\s+else\s+(['\"])(true|false)\4$",
expr,
)
if m:
truthy = m.group(2)
cond = self.ruby_value(m.group(3))
falsy = m.group(5)
return f"{cond} ? {truthy!r} : {falsy!r}"
m = re.match(
r"^(['\"])(null)\1\s+if\s+([A-Za-z_][A-Za-z0-9_\.]*)\s+is\s+none\s+else\s+([A-Za-z_][A-Za-z0-9_\.]*)$",
expr,
)
if m:
value = self.ruby_value(m.group(3))
fallback = self.ruby_value(m.group(4))
return f"{value}.nil? ? 'null' : {fallback}"
if "|" in expr:
base, *filters = [part.strip() for part in expr.split("|")]
ruby = self.ruby_value(base)
for filt in filters:
if filt.startswith("lower"):
ruby = f"{ruby}.to_s.downcase"
elif filt.startswith("to_json") or filt.startswith("tojson"):
self.needs_json = True
if "indent" in filt:
ruby = f"JSON.pretty_generate({ruby})"
else:
ruby = f"JSON.generate({ruby})"
return ruby
return self.ruby_value(expr)
def statement_to_erb(self, stmt: str) -> str:
if stmt.startswith("for "):
m = re.match(
r"^for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+([A-Za-z_][A-Za-z0-9_]*)$",
stmt,
)
if m:
loop_var, collection = m.groups()
idx_var = f"__jt_idx_{len(self.loop_stack)}"
collection_ruby = self.ruby_value(collection)
self.loop_stack.append((loop_var, idx_var, collection_ruby))
return f"<% {collection_ruby}.each_with_index do |{loop_var}, {idx_var}| -%>"
if stmt == "endfor":
if self.loop_stack:
self.loop_stack.pop()
return "<% end -%>"
if stmt.startswith("if "):
cond = stmt[3:].strip()
if cond == "not loop.last" and self.loop_stack:
_loop_var, idx_var, collection_ruby = self.loop_stack[-1]
return f"<% if {idx_var} < ({collection_ruby}.length - 1) -%>"
m = re.match(r"^([A-Za-z_][A-Za-z0-9_\.]*)\s+is\s+defined$", cond)
if m:
return f"<% unless {self.ruby_value(m.group(1))}.nil? -%>"
m = re.match(r"^([A-Za-z_][A-Za-z0-9_\.]*)\s+is\s+none$", cond)
if m:
return f"<% if {self.ruby_value(m.group(1))}.nil? -%>"
return f"<% if {self.expr_to_ruby(cond)} -%>"
if stmt == "else":
return "<% else -%>"
if stmt.startswith("elif "):
return f"<% elsif {self.expr_to_ruby(stmt[5:].strip())} -%>"
if stmt == "endif":
return "<% end -%>"
# Preserve unknown Jinja statements visibly as an ERB comment so the
# generated template does not contain invalid Jinja syntax.
return f"<%# Unsupported JinjaTurtle statement: {stmt} %>"
def translate_jinja2_to_erb(
template_text: str,
*,
role_prefix: str,
puppet_class: str | None = None,
variable_names: set[str] | None = None,
) -> str:
return ErbTranslator(
role_prefix=role_prefix,
puppet_class=puppet_class,
variable_names=variable_names,
).translate(template_text)

View file

@ -5,6 +5,7 @@ from pathlib import Path
from typing import Any from typing import Any
from . import BaseHandler from . import BaseHandler
from .. import j2
class IniHandler(BaseHandler): class IniHandler(BaseHandler):
@ -63,9 +64,9 @@ class IniHandler(BaseHandler):
var_name = self.make_var_name(role_prefix, path) var_name = self.make_var_name(role_prefix, path)
value = value.strip() value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
lines.append(f'{key} = "{{{{ {var_name} }}}}"') lines.append(f"{key} = {j2.quoted_variable(var_name)}")
else: else:
lines.append(f"{key} = {{{{ {var_name} }}}}") lines.append(f"{key} = {j2.variable(var_name)}")
lines.append("") lines.append("")
return "\n".join(lines).rstrip() + "\n" return "\n".join(lines).rstrip() + "\n"
@ -141,9 +142,9 @@ class IniHandler(BaseHandler):
if use_quotes: if use_quotes:
quote_char = raw_value[0] quote_char = raw_value[0]
replacement_value = f"{quote_char}{{{{ {var_name} }}}}{quote_char}" replacement_value = j2.quoted_variable(var_name, quote_char)
else: else:
replacement_value = f"{{{{ {var_name} }}}}" replacement_value = j2.variable(var_name)
new_content = ( new_content = (
before_eq + "=" + leading_ws + replacement_value + comment_part before_eq + "=" + leading_ws + replacement_value + comment_part

View file

@ -6,6 +6,7 @@ from pathlib import Path
from typing import Any from typing import Any
from . import DictLikeHandler from . import DictLikeHandler
from .. import j2
from ..loop_analyzer import LoopCandidate from ..loop_analyzer import LoopCandidate
@ -31,7 +32,7 @@ class JsonHandler(DictLikeHandler):
return self._generate_json_template(role_prefix, parsed) return self._generate_json_template(role_prefix, parsed)
JSON_INDENT = 2 JSON_INDENT = 2
JSON_VALUE_FILTER = "to_json(ensure_ascii=False)" JSON_VALUE_FILTER = j2.JSON_VALUE_FILTER
def _leading_indent(self, s: str, idx: int) -> int: def _leading_indent(self, s: str, idx: int) -> int:
"""Return the number of leading spaces on the line containing idx.""" """Return the number of leading spaces on the line containing idx."""
@ -85,7 +86,7 @@ class JsonHandler(DictLikeHandler):
useful in HTML, but noisy in configuration files. JinjaTurtle generates useful in HTML, but noisy in configuration files. JinjaTurtle generates
Ansible templates, so use Ansible's ``to_json`` filter instead. Ansible templates, so use Ansible's ``to_json`` filter instead.
""" """
return f"{{{{ {var_name} | {self.JSON_VALUE_FILTER} }}}}" return j2.filtered(var_name, self.JSON_VALUE_FILTER)
def _generate_json_template_from_text(self, role_prefix: str, text: str) -> str: def _generate_json_template_from_text(self, role_prefix: str, text: str) -> str:
"""Replace JSON scalar values in-place, preserving original formatting. """Replace JSON scalar values in-place, preserving original formatting.
@ -330,10 +331,10 @@ class JsonHandler(DictLikeHandler):
# a blank line between iterations under default Jinja whitespace settings. # a blank line between iterations under default Jinja whitespace settings.
return ( return (
f"[\n" f"[\n"
f"{{% for {item_var} in {collection_var} %}}" f"{j2.for_start(item_var, collection_var)}"
f"{inner}{{{{ {item_var} | to_json(ensure_ascii=False) }}}}" f"{inner}{j2.to_json(item_var)}"
f"{{% if not loop.last %}},{{% endif %}}\n" f"{j2.if_not_loop_last()},{j2.endif()}\n"
f"{{% endfor %}}{base}]" f"{j2.for_end()}{base}]"
) )
def _generate_json_dict_loop( def _generate_json_dict_loop(
@ -364,16 +365,15 @@ class JsonHandler(DictLikeHandler):
for i, key in enumerate(keys): for i, key in enumerate(keys):
comma = "," if i < len(keys) - 1 else "" comma = "," if i < len(keys) - 1 else ""
dict_lines.append( dict_lines.append(
f'{field}"{key}": ' f'{field}"{key}": ' f"{j2.to_json(f'{item_var}.{key}')}{comma}"
f"{{{{ {item_var}.{key} | to_json(ensure_ascii=False) }}}}{comma}"
) )
# Comma between *items* goes after the closing brace. # Comma between *items* goes after the closing brace.
dict_lines.append(f"{inner}}}{{% if not loop.last %}},{{% endif %}}") dict_lines.append(f"{inner}}}{j2.if_not_loop_last()},{j2.endif()}")
dict_body = "\n".join(dict_lines) dict_body = "\n".join(dict_lines)
# Put the `{% for %}` at the start of the first item line to avoid blank lines. # Put the `{% for %}` at the start of the first item line to avoid blank lines.
return ( return (
f"[\n" f"[\n"
f"{{% for {item_var} in {collection_var} %}}{inner}{dict_body}\n" f"{j2.for_start(item_var, collection_var)}{inner}{dict_body}\n"
f"{{% endfor %}}{base}]" f"{j2.for_end()}{base}]"
) )

View file

@ -4,6 +4,7 @@ from pathlib import Path
from typing import Any from typing import Any
from . import BaseHandler from . import BaseHandler
from .. import j2
class PostfixMainHandler(BaseHandler): class PostfixMainHandler(BaseHandler):
@ -92,7 +93,7 @@ class PostfixMainHandler(BaseHandler):
lines: list[str] = [] lines: list[str] = []
for k, v in parsed.items(): for k, v in parsed.items():
var = self.make_var_name(role_prefix, (k,)) var = self.make_var_name(role_prefix, (k,))
lines.append(f"{k} = {{{{ {var} }}}}") lines.append(f"{k} = {j2.variable(var)}")
return "\n".join(lines).rstrip() + "\n" return "\n".join(lines).rstrip() + "\n"
return self._generate_from_text(role_prefix, original_text) return self._generate_from_text(role_prefix, original_text)
@ -164,11 +165,13 @@ class PostfixMainHandler(BaseHandler):
quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"} quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"}
if quoted: if quoted:
replacement = ( replacement = (
f'{before_eq}={leading_ws}"{{{{ {var} }}}}"{comment_part}{newline}' f"{before_eq}={leading_ws}{j2.quoted_variable(var)}"
f"{comment_part}{newline}"
) )
else: else:
replacement = ( replacement = (
f"{before_eq}={leading_ws}{{{{ {var} }}}}{comment_part}{newline}" f"{before_eq}={leading_ws}{j2.variable(var)}"
f"{comment_part}{newline}"
) )
out_lines.append(replacement) out_lines.append(replacement)

View file

@ -6,6 +6,7 @@ from pathlib import Path
from typing import Any from typing import Any
from . import BaseHandler from . import BaseHandler
from .. import j2
_SECTION_KEYWORDS = {"host", "match"} _SECTION_KEYWORDS = {"host", "match"}
@ -265,9 +266,9 @@ class SshConfigHandler(BaseHandler):
var = self.make_var_name(role_prefix, path) var = self.make_var_name(role_prefix, path)
if ln.quoted and ln.value: if ln.quoted and ln.value:
quote_char = ln.value[0] quote_char = ln.value[0]
replacement_value = f"{quote_char}{{{{ {var} }}}}{quote_char}" replacement_value = j2.quoted_variable(var, quote_char)
else: else:
replacement_value = f"{{{{ {var} }}}}" replacement_value = j2.variable(var)
rendered = ( rendered = (
f"{ln.before_value}{replacement_value}" f"{ln.before_value}{replacement_value}"

View file

@ -5,6 +5,7 @@ from pathlib import Path
from typing import Any from typing import Any
from . import BaseHandler from . import BaseHandler
from .. import j2
@dataclass @dataclass
@ -167,9 +168,15 @@ class SystemdUnitHandler(BaseHandler):
v = (ln.value or "").strip() v = (ln.value or "").strip()
quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"} quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"}
if quoted: if quoted:
repl = f'{ln.before_eq}={ln.leading_ws_after_eq}"{{{{ {var} }}}}"{ln.comment}' repl = (
f"{ln.before_eq}={ln.leading_ws_after_eq}"
f"{j2.quoted_variable(var)}{ln.comment}"
)
else: else:
repl = f"{ln.before_eq}={ln.leading_ws_after_eq}{{{{ {var} }}}}{ln.comment}" repl = (
f"{ln.before_eq}={ln.leading_ws_after_eq}"
f"{j2.variable(var)}{ln.comment}"
)
newline = "\n" if ln.raw.endswith("\n") else "" newline = "\n" if ln.raw.endswith("\n") else ""
out_lines.append(repl + newline) out_lines.append(repl + newline)

View file

@ -4,6 +4,7 @@ from pathlib import Path
from typing import Any from typing import Any
from . import DictLikeHandler from . import DictLikeHandler
from .. import j2
from ..loop_analyzer import LoopCandidate from ..loop_analyzer import LoopCandidate
try: try:
@ -16,6 +17,14 @@ class TomlHandler(DictLikeHandler):
fmt = "toml" fmt = "toml"
flatten_lists = False # keep lists as scalars flatten_lists = False # keep lists as scalars
def _toml_value_expr(self, var_name: str, value: Any | None = None) -> str:
if isinstance(value, bool):
return j2.lower(var_name)
return j2.variable(var_name)
def _toml_quoted_expr(self, var_name: str, quote: str = '"') -> str:
return j2.quoted_variable(var_name, quote)
def parse(self, path: Path) -> Any: def parse(self, path: Path) -> Any:
if tomllib is None: if tomllib is None:
raise RuntimeError( raise RuntimeError(
@ -68,12 +77,12 @@ class TomlHandler(DictLikeHandler):
def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None: def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None:
var_name = self.make_var_name(role_prefix, path + (key,)) var_name = self.make_var_name(role_prefix, path + (key,))
if isinstance(value, str): if isinstance(value, str):
lines.append(f'{key} = "{{{{ {var_name} }}}}"') lines.append(f"{key} = {self._toml_quoted_expr(var_name)}")
elif isinstance(value, bool): elif isinstance(value, bool):
# Booleans need | lower filter (Python True/False → TOML true/false) # Booleans need | lower filter (Python True/False → TOML true/false)
lines.append(f"{key} = {{{{ {var_name} | lower }}}}") lines.append(f"{key} = {self._toml_value_expr(var_name, value)}")
else: else:
lines.append(f"{key} = {{{{ {var_name} }}}}") lines.append(f"{key} = {self._toml_value_expr(var_name, value)}")
def walk(obj: dict[str, Any], path: tuple[str, ...] = ()) -> None: def walk(obj: dict[str, Any], path: tuple[str, ...] = ()) -> None:
scalar_items = {k: v for k, v in obj.items() if not isinstance(v, dict)} scalar_items = {k: v for k, v in obj.items() if not isinstance(v, dict)}
@ -121,10 +130,10 @@ class TomlHandler(DictLikeHandler):
def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None: def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None:
var_name = self.make_var_name(role_prefix, path + (key,)) var_name = self.make_var_name(role_prefix, path + (key,))
if isinstance(value, str): if isinstance(value, str):
lines.append(f'{key} = "{{{{ {var_name} }}}}"') lines.append(f"{key} = {self._toml_quoted_expr(var_name)}")
elif isinstance(value, bool): elif isinstance(value, bool):
# Booleans need | lower filter (Python True/False → TOML true/false) # Booleans need | lower filter (Python True/False → TOML true/false)
lines.append(f"{key} = {{{{ {var_name} | lower }}}}") lines.append(f"{key} = {self._toml_value_expr(var_name, value)}")
elif isinstance(value, list): elif isinstance(value, list):
# Check if this list is a loop candidate # Check if this list is a loop candidate
if path + (key,) in loop_paths: if path + (key,) in loop_paths:
@ -139,24 +148,21 @@ class TomlHandler(DictLikeHandler):
# Scalar list loop # Scalar list loop
lines.append( lines.append(
f"{key} = [" f"{key} = ["
f"{{% for {item_var} in {collection_var} %}}" f"{j2.for_start(item_var, collection_var)}"
f"{{{{ {item_var} }}}}" f"{j2.variable(item_var)}"
f"{{% if not loop.last %}}, {{% endif %}}" f"{j2.if_not_loop_last()}, {j2.endif()}"
f"{{% endfor %}}" f"{j2.for_end()}"
f"]" f"]"
) )
elif candidate.item_schema in ("simple_dict", "nested"): elif candidate.item_schema in ("simple_dict", "nested"):
# Dict list loop - TOML array of tables # Dict list loop - TOML array of tables
# This is complex for TOML, using simplified approach # This is complex for TOML, using simplified approach
lines.append( lines.append(f"{key} = " f"{j2.to_json(var_name)}")
f"{key} = "
f"{{{{ {var_name} | to_json(ensure_ascii=False) }}}}"
)
else: else:
# Not a loop, treat as regular variable # Not a loop, treat as regular variable
lines.append(f"{key} = {{{{ {var_name} }}}}") lines.append(f"{key} = {self._toml_value_expr(var_name, value)}")
else: else:
lines.append(f"{key} = {{{{ {var_name} }}}}") lines.append(f"{key} = {self._toml_value_expr(var_name, value)}")
def walk(obj: dict[str, Any], path: tuple[str, ...] = ()) -> None: def walk(obj: dict[str, Any], path: tuple[str, ...] = ()) -> None:
scalar_items = {k: v for k, v in obj.items() if not isinstance(v, dict)} scalar_items = {k: v for k, v in obj.items() if not isinstance(v, dict)}
@ -282,13 +288,17 @@ class TomlHandler(DictLikeHandler):
nested_path = path + (sub_key,) nested_path = path + (sub_key,)
nested_var = self.make_var_name(role_prefix, nested_path) nested_var = self.make_var_name(role_prefix, nested_path)
if isinstance(sub_val, str): if isinstance(sub_val, str):
inner_bits.append(f'{sub_key} = "{{{{ {nested_var} }}}}"') inner_bits.append(
f"{sub_key} = {self._toml_quoted_expr(nested_var)}"
)
elif isinstance(sub_val, bool): elif isinstance(sub_val, bool):
inner_bits.append( inner_bits.append(
f"{sub_key} = {{{{ {nested_var} | lower }}}}" f"{sub_key} = {self._toml_value_expr(nested_var, sub_val)}"
) )
else: else:
inner_bits.append(f"{sub_key} = {{{ {nested_var} }}}") inner_bits.append(
f"{sub_key} = {self._toml_value_expr(nested_var, sub_val)}"
)
replacement_value = "{ " + ", ".join(inner_bits) + " }" replacement_value = "{ " + ", ".join(inner_bits) + " }"
new_content = ( new_content = (
before_eq + "=" + leading_ws + replacement_value + comment_part before_eq + "=" + leading_ws + replacement_value + comment_part
@ -310,11 +320,11 @@ class TomlHandler(DictLikeHandler):
if use_quotes: if use_quotes:
quote_char = raw_value[0] quote_char = raw_value[0]
replacement_value = f"{quote_char}{{{{ {var_name} }}}}{quote_char}" replacement_value = self._toml_quoted_expr(var_name, quote_char)
elif is_bool: elif is_bool:
replacement_value = f"{{{{ {var_name} | lower }}}}" replacement_value = j2.lower(var_name)
else: else:
replacement_value = f"{{{{ {var_name} }}}}" replacement_value = j2.variable(var_name)
new_content = ( new_content = (
before_eq + "=" + leading_ws + replacement_value + comment_part before_eq + "=" + leading_ws + replacement_value + comment_part
@ -392,7 +402,7 @@ class TomlHandler(DictLikeHandler):
# Build loop # Build loop
out_lines.append( out_lines.append(
f"{{% for {item_var} in {collection_var} %}}\n" f"{j2.for_start(item_var, collection_var)}\n"
) )
out_lines.append(f"[[{'.'.join(table_path)}]]\n") out_lines.append(f"[[{'.'.join(table_path)}]]\n")
@ -402,14 +412,21 @@ class TomlHandler(DictLikeHandler):
continue continue
if isinstance(value, str): if isinstance(value, str):
out_lines.append( out_lines.append(
f'{key} = "{{{{ {item_var}.{key} }}}}"\n' f"{key} = "
f"{self._toml_quoted_expr(f'{item_var}.{key}')}\n"
)
elif isinstance(value, bool):
out_lines.append(
f"{key} = "
f"{self._toml_value_expr(f'{item_var}.{key}', value)}\n"
) )
else: else:
out_lines.append( out_lines.append(
f"{key} = {{{{ {item_var}.{key} }}}}\n" f"{key} = "
f"{self._toml_value_expr(f'{item_var}.{key}', value)}\n"
) )
out_lines.append("{% endfor %}\n") out_lines.append(f"{j2.for_end()}\n")
# Skip all content until the next different table # Skip all content until the next different table
skip_until_next_table = True skip_until_next_table = True
@ -473,17 +490,15 @@ class TomlHandler(DictLikeHandler):
# Scalar list loop # Scalar list loop
replacement_value = ( replacement_value = (
f"[" f"["
f"{{% for {item_var} in {collection_var} %}}" f"{j2.for_start(item_var, collection_var)}"
f"{{{{ {item_var} }}}}" f"{j2.variable(item_var)}"
f"{{% if not loop.last %}}, {{% endif %}}" f"{j2.if_not_loop_last()}, {j2.endif()}"
f"{{% endfor %}}" f"{j2.for_end()}"
f"]" f"]"
) )
else: else:
# Dict/nested loop - use to_json filter for complex arrays # Dict/nested loop - use to_json filter for complex arrays
replacement_value = ( replacement_value = j2.to_json(collection_var)
f"{{{{ {collection_var} | to_json(ensure_ascii=False) }}}}"
)
new_content = ( new_content = (
before_eq + "=" + leading_ws + replacement_value + comment_part before_eq + "=" + leading_ws + replacement_value + comment_part
@ -510,13 +525,17 @@ class TomlHandler(DictLikeHandler):
nested_path = path + (sub_key,) nested_path = path + (sub_key,)
nested_var = self.make_var_name(role_prefix, nested_path) nested_var = self.make_var_name(role_prefix, nested_path)
if isinstance(sub_val, str): if isinstance(sub_val, str):
inner_bits.append(f'{sub_key} = "{{{{ {nested_var} }}}}"') inner_bits.append(
f"{sub_key} = {self._toml_quoted_expr(nested_var)}"
)
elif isinstance(sub_val, bool): elif isinstance(sub_val, bool):
inner_bits.append( inner_bits.append(
f"{sub_key} = {{{{ {nested_var} | lower }}}}" f"{sub_key} = {self._toml_value_expr(nested_var, sub_val)}"
) )
else: else:
inner_bits.append(f"{sub_key} = {{{{ {nested_var} }}}}") inner_bits.append(
f"{sub_key} = {self._toml_value_expr(nested_var, sub_val)}"
)
replacement_value = "{ " + ", ".join(inner_bits) + " }" replacement_value = "{ " + ", ".join(inner_bits) + " }"
new_content = ( new_content = (
before_eq + "=" + leading_ws + replacement_value + comment_part before_eq + "=" + leading_ws + replacement_value + comment_part
@ -538,11 +557,11 @@ class TomlHandler(DictLikeHandler):
if use_quotes: if use_quotes:
quote_char = raw_value[0] quote_char = raw_value[0]
replacement_value = f"{quote_char}{{{{ {var_name} }}}}{quote_char}" replacement_value = self._toml_quoted_expr(var_name, quote_char)
elif is_bool: elif is_bool:
replacement_value = f"{{{{ {var_name} | lower }}}}" replacement_value = j2.lower(var_name)
else: else:
replacement_value = f"{{{{ {var_name} }}}}" replacement_value = j2.variable(var_name)
new_content = ( new_content = (
before_eq + "=" + leading_ws + replacement_value + comment_part before_eq + "=" + leading_ws + replacement_value + comment_part

View file

@ -6,6 +6,7 @@ from typing import Any
import xml.etree.ElementTree as ET # nosec import xml.etree.ElementTree as ET # nosec
from .base import BaseHandler from .base import BaseHandler
from .. import j2
from ..loop_analyzer import LoopCandidate from ..loop_analyzer import LoopCandidate
@ -172,7 +173,7 @@ class XmlHandler(BaseHandler):
for attr_name in list(elem.attrib.keys()): for attr_name in list(elem.attrib.keys()):
attr_path = path + (f"@{attr_name}",) attr_path = path + (f"@{attr_name}",)
var_name = self.make_var_name(role_prefix, attr_path) var_name = self.make_var_name(role_prefix, attr_path)
elem.set(attr_name, f"{{{{ {var_name} }}}}") elem.set(attr_name, j2.variable(var_name))
# Children # Children
children = [c for c in list(elem) if isinstance(c.tag, str)] children = [c for c in list(elem) if isinstance(c.tag, str)]
@ -185,7 +186,7 @@ class XmlHandler(BaseHandler):
else: else:
text_path = path + ("value",) text_path = path + ("value",)
var_name = self.make_var_name(role_prefix, text_path) var_name = self.make_var_name(role_prefix, text_path)
elem.text = f"{{{{ {var_name} }}}}" elem.text = j2.variable(var_name)
# Handle children - check for loops first # Handle children - check for loops first
counts = Counter(child.tag for child in children) counts = Counter(child.tag for child in children)
@ -339,12 +340,12 @@ class XmlHandler(BaseHandler):
# Build loop # Build loop
result_lines.append( result_lines.append(
f"{indent_str}{{% for {item_var} in {collection_var} %}}" f"{indent_str}{j2.for_start(item_var, collection_var)}"
) )
# Add each line of the sample with proper indentation # Add each line of the sample with proper indentation
for sample_line in sample_lines: for sample_line in sample_lines:
result_lines.append(f"{indent_str} {sample_line}") result_lines.append(f"{indent_str} {sample_line}")
result_lines.append(f"{indent_str}{{% endfor %}}") result_lines.append(f"{indent_str}{j2.for_end()}")
else: else:
# Keep the marker if we can't find the candidate # Keep the marker if we can't find the candidate
result_lines.append(line) result_lines.append(line)
@ -360,11 +361,11 @@ class XmlHandler(BaseHandler):
end = line.find("-->", start) end = line.find("-->", start)
condition = line[start:end] condition = line[start:end]
indent = len(line) - len(line.lstrip()) indent = len(line) - len(line.lstrip())
final_lines.append(f"{' ' * indent}{{% if {condition} is defined %}}") final_lines.append(f"{' ' * indent}{j2.if_defined(condition)}")
# Replace <!--ENDIF:field--> with {% endif %} # Replace <!--ENDIF:field--> with {% endif %}
elif "<!--ENDIF:" in line: elif "<!--ENDIF:" in line:
indent = len(line) - len(line.lstrip()) indent = len(line) - len(line.lstrip())
final_lines.append(f"{' ' * indent}{{% endif %}}") final_lines.append(f"{' ' * indent}{j2.endif()}")
else: else:
final_lines.append(line) final_lines.append(line)
@ -416,13 +417,13 @@ class XmlHandler(BaseHandler):
# Attribute - these come from element attributes # Attribute - these come from element attributes
attr_name = key[1:] # Remove @ prefix attr_name = key[1:] # Remove @ prefix
# Use simple variable reference - attributes should always exist # Use simple variable reference - attributes should always exist
elem.set(attr_name, f"{{{{ {loop_var}.{attr_name} }}}}") elem.set(attr_name, j2.variable(f"{loop_var}.{attr_name}"))
elif key == "_text": elif key == "_text":
# Simple text content - use ._text accessor for dict-based items # Simple text content - use ._text accessor for dict-based items
elem.text = f"{{{{ {loop_var}._text }}}}" elem.text = j2.variable(f"{loop_var}._text")
elif key == "value": elif key == "value":
# Text with attributes/children # Text with attributes/children
elem.text = f"{{{{ {loop_var}.value }}}}" elem.text = j2.variable(f"{loop_var}.value")
elif key == "_key": elif key == "_key":
# This is the dict key (for dict collections), skip in XML # This is the dict key (for dict collections), skip in XML
pass pass
@ -431,13 +432,13 @@ class XmlHandler(BaseHandler):
# Create a conditional wrapper comment # Create a conditional wrapper comment
child = ET.Element(key) child = ET.Element(key)
if "_text" in value: if "_text" in value:
child.text = f"{{{{ {loop_var}.{key}._text }}}}" child.text = j2.variable(f"{loop_var}.{key}._text")
else: else:
# More complex nested structure # More complex nested structure
for sub_key, sub_val in value.items(): for sub_key, sub_val in value.items():
if not sub_key.startswith("_"): if not sub_key.startswith("_"):
grandchild = ET.SubElement(child, sub_key) grandchild = ET.SubElement(child, sub_key)
grandchild.text = f"{{{{ {loop_var}.{key}.{sub_key} }}}}" grandchild.text = j2.variable(f"{loop_var}.{key}.{sub_key}")
# Wrap the child in a Jinja if statement (will be done via text replacement) # Wrap the child in a Jinja if statement (will be done via text replacement)
# For now, add a marker comment before the element # For now, add a marker comment before the element
@ -452,7 +453,7 @@ class XmlHandler(BaseHandler):
marker = ET.Comment(f"IF:{loop_var}.{key}") marker = ET.Comment(f"IF:{loop_var}.{key}")
elem.append(marker) elem.append(marker)
child = ET.SubElement(elem, key) child = ET.SubElement(elem, key)
child.text = f"{{{{ {loop_var}.{key} }}}}" child.text = j2.variable(f"{loop_var}.{key}")
end_marker = ET.Comment(f"ENDIF:{key}") end_marker = ET.Comment(f"ENDIF:{key}")
elem.append(end_marker) elem.append(end_marker)

View file

@ -5,6 +5,7 @@ from pathlib import Path
from typing import Any from typing import Any
from .dict import DictLikeHandler from .dict import DictLikeHandler
from .. import j2
from ..loop_analyzer import LoopCandidate from ..loop_analyzer import LoopCandidate
@ -67,19 +68,10 @@ class YamlHandler(DictLikeHandler):
consumers are stricter than PyYAML, so emit explicit YAML spelling for consumers are stricter than PyYAML, so emit explicit YAML spelling for
values that were originally YAML booleans/nulls. values that were originally YAML booleans/nulls.
""" """
raw = (raw_value or "").strip().lower() return j2.yaml_scalar_expression(var_name, raw_value)
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: def _yaml_value_expr(self, value_expr: str, sample_value: Any | None = None) -> str:
if isinstance(sample_value, bool): return j2.yaml_value_expression(value_expr, sample_value)
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 _surrounding_quote(self, raw_value: str) -> str | None: def _surrounding_quote(self, raw_value: str) -> str | None:
if ( if (
@ -150,7 +142,7 @@ class YamlHandler(DictLikeHandler):
if use_quotes: if use_quotes:
q = raw_value[0] q = raw_value[0]
replacement = f"{q}{{{{ {var_name} }}}}{q}" replacement = j2.quoted_variable(var_name, q)
else: else:
replacement = self._yaml_scalar_expr(var_name, raw_value) replacement = self._yaml_scalar_expr(var_name, raw_value)
@ -189,7 +181,7 @@ class YamlHandler(DictLikeHandler):
if use_quotes: if use_quotes:
q = raw_value[0] q = raw_value[0]
replacement = f"{q}{{{{ {var_name} }}}}{q}" replacement = j2.quoted_variable(var_name, q)
else: else:
replacement = self._yaml_scalar_expr(var_name, raw_value) replacement = self._yaml_scalar_expr(var_name, raw_value)
@ -348,7 +340,7 @@ class YamlHandler(DictLikeHandler):
if use_quotes: if use_quotes:
q = raw_value[0] q = raw_value[0]
replacement = f"{q}{{{{ {var_name} }}}}{q}" replacement = j2.quoted_variable(var_name, q)
else: else:
replacement = self._yaml_scalar_expr(var_name, raw_value) replacement = self._yaml_scalar_expr(var_name, raw_value)
@ -412,7 +404,7 @@ class YamlHandler(DictLikeHandler):
if use_quotes: if use_quotes:
q = raw_value[0] q = raw_value[0]
replacement = f"{q}{{{{ {var_name} }}}}{q}" replacement = j2.quoted_variable(var_name, q)
else: else:
replacement = self._yaml_scalar_expr(var_name, raw_value) replacement = self._yaml_scalar_expr(var_name, raw_value)
@ -467,7 +459,7 @@ class YamlHandler(DictLikeHandler):
if candidate.item_schema == "scalar": if candidate.item_schema == "scalar":
value_expr = self._yaml_value_expr(item_var, sample_item) value_expr = self._yaml_value_expr(item_var, sample_item)
if scalar_quote and isinstance(sample_item, str): if scalar_quote and isinstance(sample_item, str):
value_expr = f"{scalar_quote}{{{{ {item_var} }}}}{scalar_quote}" value_expr = j2.quoted_variable(item_var, scalar_quote)
item_lines.append(f"{item_indent_str}- {value_expr}") item_lines.append(f"{item_indent_str}- {value_expr}")
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(
@ -480,12 +472,12 @@ class YamlHandler(DictLikeHandler):
# rendering a blank line after the parent key. Keeping the control # rendering a blank line after the parent key. Keeping the control
# tag itself at column zero prevents its indentation from leaking # tag itself at column zero prevents its indentation from leaking
# into the rendered YAML and nesting the next top-level key. # into the rendered YAML and nesting the next top-level key.
lines.append(f"{{% for {item_var} in {collection_var} %}}{item_lines[0]}") lines.append(f"{j2.for_start(item_var, collection_var)}{item_lines[0]}")
lines.extend(item_lines[1:]) lines.extend(item_lines[1:])
lines.append("{% endfor %}") lines.append(j2.for_end())
else: else:
lines.append(f"{{% for {item_var} in {collection_var} %}}") lines.append(j2.for_start(item_var, collection_var))
lines.append("{% endfor %}") lines.append(j2.for_end())
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"

87
src/jinjaturtle/j2.py Normal file
View file

@ -0,0 +1,87 @@
from __future__ import annotations
from typing import Any
NAME = "jinja2"
TEMPLATE_EXTENSION = "j2"
JSON_VALUE_FILTER = "to_json(ensure_ascii=False)"
def expression(value: str) -> str:
"""Return a Jinja2 output expression for an already-built expression body."""
return f"{{{{ {value} }}}}"
def variable(name: str) -> str:
"""Return a Jinja2 output expression for a variable name."""
return expression(name)
def quoted_variable(name: str, quote: str = '"') -> str:
"""Return a quoted Jinja2 variable placeholder."""
return f"{quote}{variable(name)}{quote}"
def filtered(value: str, filter_expression: str) -> str:
"""Return a Jinja2 output expression with one filter expression applied."""
return expression(f"{value} | {filter_expression}")
def lower(value: str) -> str:
"""Return a Jinja2 expression that renders a value through ``| lower``."""
return filtered(value, "lower")
def to_json(
value: str, *, indent: int | None = None, ensure_ascii: bool = False
) -> str:
"""Return a Jinja2 expression using Ansible's ``to_json`` filter."""
args: list[str] = []
if indent is not None:
args.append(f"indent={indent}")
args.append(f"ensure_ascii={ensure_ascii}")
return filtered(value, f"to_json({', '.join(args)})")
def statement(value: str) -> str:
"""Return a Jinja2 statement tag for an already-built statement body."""
return f"{{% {value} %}}"
def if_defined(name: str) -> str:
return statement(f"if {name} is defined")
def if_not_loop_last() -> str:
return statement("if not loop.last")
def endif() -> str:
return statement("endif")
def for_start(item_var: str, collection_var: str) -> str:
return statement(f"for {item_var} in {collection_var}")
def for_end() -> str:
return statement("endfor")
def yaml_scalar_expression(var_name: str, raw_value: str | None = None) -> str:
"""Return a YAML-safe Jinja2 expression for a scalar value."""
raw = (raw_value or "").strip().lower()
if raw in {"true", "false"}:
return expression(f"'true' if {var_name} else 'false'")
if raw in {"null", "~"}:
return expression(f"'null' if {var_name} is none else {var_name}")
return variable(var_name)
def yaml_value_expression(value_expr: str, sample_value: Any | None = None) -> str:
"""Return a YAML-safe Jinja2 expression for a possibly typed sample value."""
if isinstance(sample_value, bool):
return expression(f"'true' if {value_expr} else 'false'")
if sample_value is None:
return expression(f"'null' if {value_expr} is none else {value_expr}")
return expression(value_expr)

View file

@ -28,6 +28,7 @@ from pathlib import Path
from typing import Any, Iterable from typing import Any, Iterable
import xml.etree.ElementTree as ET # nosec import xml.etree.ElementTree as ET # nosec
from . import j2
from .core import dump_yaml, flatten_config, make_var_name, parse_config from .core import dump_yaml, flatten_config, make_var_name, parse_config
from .handlers.xml import XmlHandler from .handlers.xml import XmlHandler
@ -140,8 +141,8 @@ def _yaml_scalar_placeholder(
) -> str: ) -> str:
var = make_var_name(role_prefix, path) var = make_var_name(role_prefix, path)
if isinstance(sample, str): if isinstance(sample, str):
return f'"{{{{ {var} }}}}"' return j2.quoted_variable(var)
return f"{{{{ {var} }}}}" return j2.variable(var)
def _yaml_render_union( def _yaml_render_union(
@ -168,13 +169,13 @@ def _yaml_render_union(
if _is_scalar(val) or val is None: if _is_scalar(val) or val is None:
value = _yaml_scalar_placeholder(role_prefix, key_path, val) value = _yaml_scalar_placeholder(role_prefix, key_path, val)
if cond_var: if cond_var:
lines.append(f"{ind}{{% if {cond_var} is defined %}}") lines.append(f"{ind}{j2.if_defined(cond_var)}")
lines.append(f"{ind}{key}: {value}") lines.append(f"{ind}{key}: {value}")
if cond_var: if cond_var:
lines.append(f"{ind}{{% endif %}}") lines.append(f"{ind}{j2.endif()}")
else: else:
if cond_var: if cond_var:
lines.append(f"{ind}{{% if {cond_var} is defined %}}") lines.append(f"{ind}{j2.if_defined(cond_var)}")
lines.append(f"{ind}{key}:") lines.append(f"{ind}{key}:")
lines.extend( lines.extend(
_yaml_render_union( _yaml_render_union(
@ -187,7 +188,7 @@ def _yaml_render_union(
) )
) )
if cond_var: if cond_var:
lines.append(f"{ind}{{% endif %}}") lines.append(f"{ind}{j2.endif()}")
return lines return lines
if isinstance(union_obj, list): if isinstance(union_obj, list):
@ -202,13 +203,13 @@ def _yaml_render_union(
if _is_scalar(item) or item is None: if _is_scalar(item) or item is None:
value = _yaml_scalar_placeholder(role_prefix, item_path, item) value = _yaml_scalar_placeholder(role_prefix, item_path, item)
if cond_var: if cond_var:
lines.append(f"{ind}{{% if {cond_var} is defined %}}") lines.append(f"{ind}{j2.if_defined(cond_var)}")
lines.append(f"{ind}- {value}") lines.append(f"{ind}- {value}")
if cond_var: if cond_var:
lines.append(f"{ind}{{% endif %}}") lines.append(f"{ind}{j2.endif()}")
elif isinstance(item, dict): elif isinstance(item, dict):
if cond_var: if cond_var:
lines.append(f"{ind}{{% if {cond_var} is defined %}}") lines.append(f"{ind}{j2.if_defined(cond_var)}")
# First line: list marker with first key if possible # First line: list marker with first key if possible
first = True first = True
for k, v in item.items(): for k, v in item.items():
@ -222,22 +223,22 @@ def _yaml_render_union(
value = _yaml_scalar_placeholder(role_prefix, kp, v) value = _yaml_scalar_placeholder(role_prefix, kp, v)
if first: if first:
if k_cond: if k_cond:
lines.append(f"{ind}{{% if {k_cond} is defined %}}") lines.append(f"{ind}{j2.if_defined(k_cond)}")
lines.append(f"{ind}- {k}: {value}") lines.append(f"{ind}- {k}: {value}")
if k_cond: if k_cond:
lines.append(f"{ind}{{% endif %}}") lines.append(f"{ind}{j2.endif()}")
first = False first = False
else: else:
if k_cond: if k_cond:
lines.append(f"{ind} {{% if {k_cond} is defined %}}") lines.append(f"{ind} {j2.if_defined(k_cond)}")
lines.append(f"{ind} {k}: {value}") lines.append(f"{ind} {k}: {value}")
if k_cond: if k_cond:
lines.append(f"{ind} {{% endif %}}") lines.append(f"{ind} {j2.endif()}")
else: else:
# nested # nested
if first: if first:
if k_cond: if k_cond:
lines.append(f"{ind}{{% if {k_cond} is defined %}}") lines.append(f"{ind}{j2.if_defined(k_cond)}")
lines.append(f"{ind}- {k}:") lines.append(f"{ind}- {k}:")
lines.extend( lines.extend(
_yaml_render_union( _yaml_render_union(
@ -249,11 +250,11 @@ def _yaml_render_union(
) )
) )
if k_cond: if k_cond:
lines.append(f"{ind}{{% endif %}}") lines.append(f"{ind}{j2.endif()}")
first = False first = False
else: else:
if k_cond: if k_cond:
lines.append(f"{ind} {{% if {k_cond} is defined %}}") lines.append(f"{ind} {j2.if_defined(k_cond)}")
lines.append(f"{ind} {k}:") lines.append(f"{ind} {k}:")
lines.extend( lines.extend(
_yaml_render_union( _yaml_render_union(
@ -265,20 +266,20 @@ def _yaml_render_union(
) )
) )
if k_cond: if k_cond:
lines.append(f"{ind} {{% endif %}}") lines.append(f"{ind} {j2.endif()}")
if first: if first:
# empty dict item # empty dict item
lines.append(f"{ind}- {{}}") lines.append(f"{ind}- {{}}")
if cond_var: if cond_var:
lines.append(f"{ind}{{% endif %}}") lines.append(f"{ind}{j2.endif()}")
else: else:
# list of lists - emit as scalar-ish fallback # list of lists - emit as scalar-ish fallback
value = f"{{{{ {make_var_name(role_prefix, item_path)} }}}}" value = j2.variable(make_var_name(role_prefix, item_path))
if cond_var: if cond_var:
lines.append(f"{ind}{{% if {cond_var} is defined %}}") lines.append(f"{ind}{j2.if_defined(cond_var)}")
lines.append(f"{ind}- {value}") lines.append(f"{ind}- {value}")
if cond_var: if cond_var:
lines.append(f"{ind}{{% endif %}}") lines.append(f"{ind}{j2.endif()}")
return lines return lines
# scalar at root # scalar at root
@ -306,15 +307,15 @@ def _toml_render_union(
else None else None
) )
if cond: if cond:
lines.append(f"{{% if {cond} is defined %}}") lines.append(f"{j2.if_defined(cond)}")
if isinstance(value, str): if isinstance(value, str):
lines.append(f'{key} = "{{{{ {var_name} }}}}"') lines.append(f"{key} = {j2.quoted_variable(var_name)}")
elif isinstance(value, bool): elif isinstance(value, bool):
lines.append(f"{key} = {{{{ {var_name} | lower }}}}") lines.append(f"{key} = {j2.lower(var_name)}")
else: else:
lines.append(f"{key} = {{{{ {var_name} }}}}") lines.append(f"{key} = {j2.variable(var_name)}")
if cond: if cond:
lines.append("{% endif %}") lines.append(j2.endif())
def walk(obj: dict[str, Any], path: tuple[str, ...]) -> None: def walk(obj: dict[str, Any], path: tuple[str, ...]) -> None:
if path: if path:
@ -324,7 +325,7 @@ def _toml_render_union(
else None else None
) )
if cond: if cond:
lines.append(f"{{% if {cond} is defined %}}") lines.append(f"{j2.if_defined(cond)}")
lines.append(f"[{'.'.join(path)}]") lines.append(f"[{'.'.join(path)}]")
scalar_items = {k: v for k, v in obj.items() if not isinstance(v, dict)} scalar_items = {k: v for k, v in obj.items() if not isinstance(v, dict)}
@ -340,7 +341,7 @@ def _toml_render_union(
walk(v, path + (str(k),)) walk(v, path + (str(k),))
if path and (path in optional_containers): if path and (path in optional_containers):
lines.append("{% endif %}") lines.append(j2.endif())
lines.append("") lines.append("")
# root scalars # root scalars
@ -410,7 +411,7 @@ def _ini_render_union(
else None else None
) )
if sec_cond: if sec_cond:
lines.append(f"{{% if {sec_cond} is defined %}}") lines.append(f"{j2.if_defined(sec_cond)}")
lines.append(f"[{section}]") lines.append(f"[{section}]")
for key, raw_val in union.items(section, raw=True): for key, raw_val in union.items(section, raw=True):
path = (section, key) path = (section, key)
@ -421,16 +422,16 @@ def _ini_render_union(
v = (raw_val or "").strip() v = (raw_val or "").strip()
quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"} quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"}
if key_cond: if key_cond:
lines.append(f"{{% if {key_cond} is defined %}}") lines.append(f"{j2.if_defined(key_cond)}")
if quoted: if quoted:
lines.append(f'{key} = "{{{{ {var} }}}}"') lines.append(f"{key} = {j2.quoted_variable(var)}")
else: else:
lines.append(f"{key} = {{{{ {var} }}}}") lines.append(f"{key} = {j2.variable(var)}")
if key_cond: if key_cond:
lines.append("{% endif %}") lines.append(j2.endif())
lines.append("") lines.append("")
if sec_cond: if sec_cond:
lines.append("{% endif %}") lines.append(j2.endif())
lines.append("") lines.append("")
return "\n".join(lines).rstrip() + "\n" return "\n".join(lines).rstrip() + "\n"
@ -624,7 +625,7 @@ def process_directory(
if multiple_formats if multiple_formats
else f"{role_prefix}_items" else f"{role_prefix}_items"
) )
template = "{{ data | to_json(indent=2, ensure_ascii=False) }}\n" template = f"{j2.to_json('data', indent=2)}\n"
items: list[dict[str, Any]] = [] items: list[dict[str, Any]] = []
for rid, parsed in zip(rel_ids, parsed_list): for rid, parsed in zip(rel_ids, parsed_list):
items.append({"id": rid, "data": parsed}) items.append({"id": rid, "data": parsed})

View file

@ -132,3 +132,57 @@ def test_cli_folder_single_output_file_when_one_format(tmp_path):
assert defaults_path.is_file() assert defaults_path.is_file()
assert template_path.is_file() assert template_path.is_file()
assert "to_json" in template_path.read_text(encoding="utf-8") assert "to_json" in template_path.read_text(encoding="utf-8")
def test_cli_erb_outputs_puppet_hiera_and_erb_template(tmp_path):
cfg = tmp_path / "app.ini"
cfg.write_text("[main]\nport = 8080\n", encoding="utf-8")
data_out = tmp_path / "common.yaml"
template_out = tmp_path / "app.ini.erb"
exit_code = cli._main(
[
str(cfg),
"-r",
"php",
"--template-engine",
"erb",
"--defaults-output",
str(data_out),
"--template-output",
str(template_out),
]
)
assert exit_code == 0
assert "php::main_port: '8080'" in data_out.read_text(encoding="utf-8")
assert "port = <%= @main_port %>" in template_out.read_text(encoding="utf-8")
def test_cli_erb_can_use_separate_puppet_class_namespace(tmp_path):
cfg = tmp_path / "app.ini"
cfg.write_text("[main]\nport = 8080\n", encoding="utf-8")
data_out = tmp_path / "node.yaml"
template_out = tmp_path / "app.ini.erb"
exit_code = cli._main(
[
str(cfg),
"-r",
"php_etc_app_ini",
"--template-engine",
"erb",
"--puppet-class",
"php",
"--defaults-output",
str(data_out),
"--template-output",
str(template_out),
]
)
assert exit_code == 0
data = data_out.read_text(encoding="utf-8")
template = template_out.read_text(encoding="utf-8")
assert "php::php_etc_app_ini_main_port: '8080'" in data
assert "port = <%= @php_etc_app_ini_main_port %>" in template