Add erb support
This commit is contained in:
parent
5ee084d395
commit
17612c9883
14 changed files with 661 additions and 141 deletions
|
|
@ -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,8 +145,27 @@ 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)
|
||||
|
||||
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)
|
||||
ansible_yaml = generate_ansible_yaml(
|
||||
args.role_name, flat_items, loop_candidates
|
||||
)
|
||||
|
||||
# Generate template (with loops if detected)
|
||||
template_str = generate_jinja2_template(
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
215
src/jinjaturtle/erb.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}]"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
87
src/jinjaturtle/j2.py
Normal 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)
|
||||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue