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 pathlib import Path
from . import j2
from .core import (
parse_config,
analyze_loops,
flatten_config,
generate_ansible_yaml,
generate_jinja2_template,
generate_puppet_hiera_yaml,
generate_erb_template,
)
from .multi import process_directory
@ -53,7 +56,21 @@ def _build_arg_parser() -> argparse.ArgumentParser:
ap.add_argument(
"-t",
"--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
@ -78,6 +95,21 @@ def _main(argv: list[str] | None = None) -> int:
print("# defaults/main.yml")
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
if args.template_output:
out_path = Path(args.template_output)
@ -86,12 +118,16 @@ def _main(argv: list[str] | None = None) -> int:
else:
out_path.mkdir(parents=True, exist_ok=True)
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"
)
else:
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(o.template, end="")
@ -109,17 +145,36 @@ def _main(argv: list[str] | None = None) -> int:
# Flatten config (excluding loop paths if loops are detected)
flat_items = flatten_config(fmt, parsed, loop_candidates)
# Generate defaults YAML (with loop collections if detected)
ansible_yaml = generate_ansible_yaml(args.role_name, flat_items, loop_candidates)
if args.template_engine == "erb":
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)
template_str = generate_jinja2_template(
fmt,
parsed,
args.role_name,
original_text=config_text,
loop_candidates=loop_candidates,
)
# Generate template (with loops if detected)
template_str = generate_jinja2_template(
fmt,
parsed,
args.role_name,
original_text=config_text,
loop_candidates=loop_candidates,
)
if args.defaults_output:
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:
Path(args.template_output).write_text(template_str, encoding="utf-8")
else:
print("# config.j2")
print(
"# config.erb"
if args.template_engine == "erb"
else f"# config.{j2.TEMPLATE_EXTENSION}"
)
print(template_str, end="")
return 0

View file

@ -8,6 +8,7 @@ import re
import yaml
from .loop_analyzer import LoopAnalyzer, LoopCandidate
from .erb import puppet_class_name, puppet_local_var_name, translate_jinja2_to_erb
from .handlers import (
BaseHandler,
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:
"""
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 . import BaseHandler
from .. import j2
class IniHandler(BaseHandler):
@ -63,9 +64,9 @@ class IniHandler(BaseHandler):
var_name = self.make_var_name(role_prefix, path)
value = value.strip()
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:
lines.append(f"{key} = {{{{ {var_name} }}}}")
lines.append(f"{key} = {j2.variable(var_name)}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
@ -141,9 +142,9 @@ class IniHandler(BaseHandler):
if use_quotes:
quote_char = raw_value[0]
replacement_value = f"{quote_char}{{{{ {var_name} }}}}{quote_char}"
replacement_value = j2.quoted_variable(var_name, quote_char)
else:
replacement_value = f"{{{{ {var_name} }}}}"
replacement_value = j2.variable(var_name)
new_content = (
before_eq + "=" + leading_ws + replacement_value + comment_part

View file

@ -6,6 +6,7 @@ from pathlib import Path
from typing import Any
from . import DictLikeHandler
from .. import j2
from ..loop_analyzer import LoopCandidate
@ -31,7 +32,7 @@ class JsonHandler(DictLikeHandler):
return self._generate_json_template(role_prefix, parsed)
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:
"""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
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:
"""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.
return (
f"[\n"
f"{{% for {item_var} in {collection_var} %}}"
f"{inner}{{{{ {item_var} | to_json(ensure_ascii=False) }}}}"
f"{{% if not loop.last %}},{{% endif %}}\n"
f"{{% endfor %}}{base}]"
f"{j2.for_start(item_var, collection_var)}"
f"{inner}{j2.to_json(item_var)}"
f"{j2.if_not_loop_last()},{j2.endif()}\n"
f"{j2.for_end()}{base}]"
)
def _generate_json_dict_loop(
@ -364,16 +365,15 @@ class JsonHandler(DictLikeHandler):
for i, key in enumerate(keys):
comma = "," if i < len(keys) - 1 else ""
dict_lines.append(
f'{field}"{key}": '
f"{{{{ {item_var}.{key} | to_json(ensure_ascii=False) }}}}{comma}"
f'{field}"{key}": ' f"{j2.to_json(f'{item_var}.{key}')}{comma}"
)
# 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)
# 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}]"
f"{j2.for_start(item_var, collection_var)}{inner}{dict_body}\n"
f"{j2.for_end()}{base}]"
)

View file

@ -4,6 +4,7 @@ from pathlib import Path
from typing import Any
from . import BaseHandler
from .. import j2
class PostfixMainHandler(BaseHandler):
@ -92,7 +93,7 @@ class PostfixMainHandler(BaseHandler):
lines: list[str] = []
for k, v in parsed.items():
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 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 {'"', "'"}
if quoted:
replacement = (
f'{before_eq}={leading_ws}"{{{{ {var} }}}}"{comment_part}{newline}'
f"{before_eq}={leading_ws}{j2.quoted_variable(var)}"
f"{comment_part}{newline}"
)
else:
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)

View file

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

View file

@ -5,6 +5,7 @@ from pathlib import Path
from typing import Any
from . import BaseHandler
from .. import j2
@dataclass
@ -167,9 +168,15 @@ class SystemdUnitHandler(BaseHandler):
v = (ln.value or "").strip()
quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"}
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:
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 ""
out_lines.append(repl + newline)

View file

@ -4,6 +4,7 @@ from pathlib import Path
from typing import Any
from . import DictLikeHandler
from .. import j2
from ..loop_analyzer import LoopCandidate
try:
@ -16,6 +17,14 @@ class TomlHandler(DictLikeHandler):
fmt = "toml"
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:
if tomllib is None:
raise RuntimeError(
@ -68,12 +77,12 @@ class TomlHandler(DictLikeHandler):
def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None:
var_name = self.make_var_name(role_prefix, path + (key,))
if isinstance(value, str):
lines.append(f'{key} = "{{{{ {var_name} }}}}"')
lines.append(f"{key} = {self._toml_quoted_expr(var_name)}")
elif isinstance(value, bool):
# 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:
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:
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:
var_name = self.make_var_name(role_prefix, path + (key,))
if isinstance(value, str):
lines.append(f'{key} = "{{{{ {var_name} }}}}"')
lines.append(f"{key} = {self._toml_quoted_expr(var_name)}")
elif isinstance(value, bool):
# 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):
# Check if this list is a loop candidate
if path + (key,) in loop_paths:
@ -139,24 +148,21 @@ class TomlHandler(DictLikeHandler):
# Scalar list loop
lines.append(
f"{key} = ["
f"{{% for {item_var} in {collection_var} %}}"
f"{{{{ {item_var} }}}}"
f"{{% if not loop.last %}}, {{% endif %}}"
f"{{% endfor %}}"
f"{j2.for_start(item_var, collection_var)}"
f"{j2.variable(item_var)}"
f"{j2.if_not_loop_last()}, {j2.endif()}"
f"{j2.for_end()}"
f"]"
)
elif candidate.item_schema in ("simple_dict", "nested"):
# Dict list loop - TOML array of tables
# This is complex for TOML, using simplified approach
lines.append(
f"{key} = "
f"{{{{ {var_name} | to_json(ensure_ascii=False) }}}}"
)
lines.append(f"{key} = " f"{j2.to_json(var_name)}")
else:
# 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:
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:
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_var = self.make_var_name(role_prefix, nested_path)
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):
inner_bits.append(
f"{sub_key} = {{{{ {nested_var} | lower }}}}"
f"{sub_key} = {self._toml_value_expr(nested_var, sub_val)}"
)
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) + " }"
new_content = (
before_eq + "=" + leading_ws + replacement_value + comment_part
@ -310,11 +320,11 @@ class TomlHandler(DictLikeHandler):
if use_quotes:
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:
replacement_value = f"{{{{ {var_name} | lower }}}}"
replacement_value = j2.lower(var_name)
else:
replacement_value = f"{{{{ {var_name} }}}}"
replacement_value = j2.variable(var_name)
new_content = (
before_eq + "=" + leading_ws + replacement_value + comment_part
@ -392,7 +402,7 @@ class TomlHandler(DictLikeHandler):
# Build loop
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")
@ -402,14 +412,21 @@ class TomlHandler(DictLikeHandler):
continue
if isinstance(value, str):
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:
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_until_next_table = True
@ -473,17 +490,15 @@ class TomlHandler(DictLikeHandler):
# Scalar list loop
replacement_value = (
f"["
f"{{% for {item_var} in {collection_var} %}}"
f"{{{{ {item_var} }}}}"
f"{{% if not loop.last %}}, {{% endif %}}"
f"{{% endfor %}}"
f"{j2.for_start(item_var, collection_var)}"
f"{j2.variable(item_var)}"
f"{j2.if_not_loop_last()}, {j2.endif()}"
f"{j2.for_end()}"
f"]"
)
else:
# Dict/nested loop - use to_json filter for complex arrays
replacement_value = (
f"{{{{ {collection_var} | to_json(ensure_ascii=False) }}}}"
)
replacement_value = j2.to_json(collection_var)
new_content = (
before_eq + "=" + leading_ws + replacement_value + comment_part
@ -510,13 +525,17 @@ class TomlHandler(DictLikeHandler):
nested_path = path + (sub_key,)
nested_var = self.make_var_name(role_prefix, nested_path)
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):
inner_bits.append(
f"{sub_key} = {{{{ {nested_var} | lower }}}}"
f"{sub_key} = {self._toml_value_expr(nested_var, sub_val)}"
)
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) + " }"
new_content = (
before_eq + "=" + leading_ws + replacement_value + comment_part
@ -538,11 +557,11 @@ class TomlHandler(DictLikeHandler):
if use_quotes:
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:
replacement_value = f"{{{{ {var_name} | lower }}}}"
replacement_value = j2.lower(var_name)
else:
replacement_value = f"{{{{ {var_name} }}}}"
replacement_value = j2.variable(var_name)
new_content = (
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
from .base import BaseHandler
from .. import j2
from ..loop_analyzer import LoopCandidate
@ -172,7 +173,7 @@ class XmlHandler(BaseHandler):
for attr_name in list(elem.attrib.keys()):
attr_path = path + (f"@{attr_name}",)
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 = [c for c in list(elem) if isinstance(c.tag, str)]
@ -185,7 +186,7 @@ class XmlHandler(BaseHandler):
else:
text_path = path + ("value",)
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
counts = Counter(child.tag for child in children)
@ -339,12 +340,12 @@ class XmlHandler(BaseHandler):
# Build loop
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
for sample_line in sample_lines:
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:
# Keep the marker if we can't find the candidate
result_lines.append(line)
@ -360,11 +361,11 @@ class XmlHandler(BaseHandler):
end = line.find("-->", start)
condition = line[start:end]
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 %}
elif "<!--ENDIF:" in line:
indent = len(line) - len(line.lstrip())
final_lines.append(f"{' ' * indent}{{% endif %}}")
final_lines.append(f"{' ' * indent}{j2.endif()}")
else:
final_lines.append(line)
@ -416,13 +417,13 @@ class XmlHandler(BaseHandler):
# Attribute - these come from element attributes
attr_name = key[1:] # Remove @ prefix
# 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":
# 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":
# Text with attributes/children
elem.text = f"{{{{ {loop_var}.value }}}}"
elem.text = j2.variable(f"{loop_var}.value")
elif key == "_key":
# This is the dict key (for dict collections), skip in XML
pass
@ -431,13 +432,13 @@ class XmlHandler(BaseHandler):
# Create a conditional wrapper comment
child = ET.Element(key)
if "_text" in value:
child.text = f"{{{{ {loop_var}.{key}._text }}}}"
child.text = j2.variable(f"{loop_var}.{key}._text")
else:
# More complex nested structure
for sub_key, sub_val in value.items():
if not sub_key.startswith("_"):
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)
# For now, add a marker comment before the element
@ -452,7 +453,7 @@ class XmlHandler(BaseHandler):
marker = ET.Comment(f"IF:{loop_var}.{key}")
elem.append(marker)
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}")
elem.append(end_marker)

View file

@ -5,6 +5,7 @@ from pathlib import Path
from typing import Any
from .dict import DictLikeHandler
from .. import j2
from ..loop_analyzer import LoopCandidate
@ -67,19 +68,10 @@ class YamlHandler(DictLikeHandler):
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} }}}}"
return j2.yaml_scalar_expression(var_name, raw_value)
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} }}}}"
return j2.yaml_value_expression(value_expr, sample_value)
def _surrounding_quote(self, raw_value: str) -> str | None:
if (
@ -150,7 +142,7 @@ class YamlHandler(DictLikeHandler):
if use_quotes:
q = raw_value[0]
replacement = f"{q}{{{{ {var_name} }}}}{q}"
replacement = j2.quoted_variable(var_name, q)
else:
replacement = self._yaml_scalar_expr(var_name, raw_value)
@ -189,7 +181,7 @@ class YamlHandler(DictLikeHandler):
if use_quotes:
q = raw_value[0]
replacement = f"{q}{{{{ {var_name} }}}}{q}"
replacement = j2.quoted_variable(var_name, q)
else:
replacement = self._yaml_scalar_expr(var_name, raw_value)
@ -348,7 +340,7 @@ class YamlHandler(DictLikeHandler):
if use_quotes:
q = raw_value[0]
replacement = f"{q}{{{{ {var_name} }}}}{q}"
replacement = j2.quoted_variable(var_name, q)
else:
replacement = self._yaml_scalar_expr(var_name, raw_value)
@ -412,7 +404,7 @@ class YamlHandler(DictLikeHandler):
if use_quotes:
q = raw_value[0]
replacement = f"{q}{{{{ {var_name} }}}}{q}"
replacement = j2.quoted_variable(var_name, q)
else:
replacement = self._yaml_scalar_expr(var_name, raw_value)
@ -467,7 +459,7 @@ class YamlHandler(DictLikeHandler):
if candidate.item_schema == "scalar":
value_expr = self._yaml_value_expr(item_var, sample_item)
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}")
elif candidate.item_schema in ("simple_dict", "nested"):
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
# tag itself at column zero prevents its indentation from leaking
# 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.append("{% endfor %}")
lines.append(j2.for_end())
else:
lines.append(f"{{% for {item_var} in {collection_var} %}}")
lines.append("{% endfor %}")
lines.append(j2.for_start(item_var, collection_var))
lines.append(j2.for_end())
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
import xml.etree.ElementTree as ET # nosec
from . import j2
from .core import dump_yaml, flatten_config, make_var_name, parse_config
from .handlers.xml import XmlHandler
@ -140,8 +141,8 @@ def _yaml_scalar_placeholder(
) -> str:
var = make_var_name(role_prefix, path)
if isinstance(sample, str):
return f'"{{{{ {var} }}}}"'
return f"{{{{ {var} }}}}"
return j2.quoted_variable(var)
return j2.variable(var)
def _yaml_render_union(
@ -168,13 +169,13 @@ def _yaml_render_union(
if _is_scalar(val) or val is None:
value = _yaml_scalar_placeholder(role_prefix, key_path, val)
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}")
if cond_var:
lines.append(f"{ind}{{% endif %}}")
lines.append(f"{ind}{j2.endif()}")
else:
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.extend(
_yaml_render_union(
@ -187,7 +188,7 @@ def _yaml_render_union(
)
)
if cond_var:
lines.append(f"{ind}{{% endif %}}")
lines.append(f"{ind}{j2.endif()}")
return lines
if isinstance(union_obj, list):
@ -202,13 +203,13 @@ def _yaml_render_union(
if _is_scalar(item) or item is None:
value = _yaml_scalar_placeholder(role_prefix, item_path, item)
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}")
if cond_var:
lines.append(f"{ind}{{% endif %}}")
lines.append(f"{ind}{j2.endif()}")
elif isinstance(item, dict):
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 = True
for k, v in item.items():
@ -222,22 +223,22 @@ def _yaml_render_union(
value = _yaml_scalar_placeholder(role_prefix, kp, v)
if first:
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}")
if k_cond:
lines.append(f"{ind}{{% endif %}}")
lines.append(f"{ind}{j2.endif()}")
first = False
else:
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}")
if k_cond:
lines.append(f"{ind} {{% endif %}}")
lines.append(f"{ind} {j2.endif()}")
else:
# nested
if first:
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.extend(
_yaml_render_union(
@ -249,11 +250,11 @@ def _yaml_render_union(
)
)
if k_cond:
lines.append(f"{ind}{{% endif %}}")
lines.append(f"{ind}{j2.endif()}")
first = False
else:
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.extend(
_yaml_render_union(
@ -265,20 +266,20 @@ def _yaml_render_union(
)
)
if k_cond:
lines.append(f"{ind} {{% endif %}}")
lines.append(f"{ind} {j2.endif()}")
if first:
# empty dict item
lines.append(f"{ind}- {{}}")
if cond_var:
lines.append(f"{ind}{{% endif %}}")
lines.append(f"{ind}{j2.endif()}")
else:
# 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:
lines.append(f"{ind}{{% if {cond_var} is defined %}}")
lines.append(f"{ind}{j2.if_defined(cond_var)}")
lines.append(f"{ind}- {value}")
if cond_var:
lines.append(f"{ind}{{% endif %}}")
lines.append(f"{ind}{j2.endif()}")
return lines
# scalar at root
@ -306,15 +307,15 @@ def _toml_render_union(
else None
)
if cond:
lines.append(f"{{% if {cond} is defined %}}")
lines.append(f"{j2.if_defined(cond)}")
if isinstance(value, str):
lines.append(f'{key} = "{{{{ {var_name} }}}}"')
lines.append(f"{key} = {j2.quoted_variable(var_name)}")
elif isinstance(value, bool):
lines.append(f"{key} = {{{{ {var_name} | lower }}}}")
lines.append(f"{key} = {j2.lower(var_name)}")
else:
lines.append(f"{key} = {{{{ {var_name} }}}}")
lines.append(f"{key} = {j2.variable(var_name)}")
if cond:
lines.append("{% endif %}")
lines.append(j2.endif())
def walk(obj: dict[str, Any], path: tuple[str, ...]) -> None:
if path:
@ -324,7 +325,7 @@ def _toml_render_union(
else None
)
if cond:
lines.append(f"{{% if {cond} is defined %}}")
lines.append(f"{j2.if_defined(cond)}")
lines.append(f"[{'.'.join(path)}]")
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),))
if path and (path in optional_containers):
lines.append("{% endif %}")
lines.append(j2.endif())
lines.append("")
# root scalars
@ -410,7 +411,7 @@ def _ini_render_union(
else None
)
if sec_cond:
lines.append(f"{{% if {sec_cond} is defined %}}")
lines.append(f"{j2.if_defined(sec_cond)}")
lines.append(f"[{section}]")
for key, raw_val in union.items(section, raw=True):
path = (section, key)
@ -421,16 +422,16 @@ def _ini_render_union(
v = (raw_val or "").strip()
quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"}
if key_cond:
lines.append(f"{{% if {key_cond} is defined %}}")
lines.append(f"{j2.if_defined(key_cond)}")
if quoted:
lines.append(f'{key} = "{{{{ {var} }}}}"')
lines.append(f"{key} = {j2.quoted_variable(var)}")
else:
lines.append(f"{key} = {{{{ {var} }}}}")
lines.append(f"{key} = {j2.variable(var)}")
if key_cond:
lines.append("{% endif %}")
lines.append(j2.endif())
lines.append("")
if sec_cond:
lines.append("{% endif %}")
lines.append(j2.endif())
lines.append("")
return "\n".join(lines).rstrip() + "\n"
@ -624,7 +625,7 @@ def process_directory(
if multiple_formats
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]] = []
for rid, parsed in zip(rel_ids, parsed_list):
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 template_path.is_file()
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