diff --git a/README.md b/README.md index 0e0ad48..26730fb 100644 --- a/README.md +++ b/README.md @@ -4,55 +4,230 @@ JinjaTurtle logo -JinjaTurtle is a command-line tool to help you generate Jinja2 templates and -Ansible inventory from a native configuration file (or files) of a piece of -software. +JinjaTurtle is a command-line tool that helps turn existing native +configuration files into reusable configuration-management templates. + +By default it generates: + +- a **Jinja2** template; and +- an **Ansible defaults YAML** file containing the variables used by that + template. + +It can also generate **ERB** templates and **Puppet Hiera-style YAML** data for +Puppet workflows. + +JinjaTurtle does not try to replace configuration-management tools. Its job is +to speed up the boring first pass: take a real config file, discover the values +inside it, replace those values with variables, and write the corresponding +variable data beside the template. ## How it works - * The config file(s) is/are examined - * Parameter key names are generated based on the parameter names in the - config file. In keeping with Ansible best practices, you pass a prefix - for the key names, which should typically match the name of your Ansible - role. - * A Jinja2 file is generated from the file with those parameter key names - injected as the `{{ variable }}` names. - * An Ansible inventory YAML file is generated with those key names and the - *values* taken from the original config file as the default vars. +JinjaTurtle examines a source config file and keeps the original structure as +much as possible. -By default, the Jinja2 template and the Ansible inventory are printed to -stdout. However, it is possible to output the results to new files. +For the default Jinja2/Ansible mode: + +1. The config file is parsed. +2. Variable names are generated from the config keys and paths. +3. Those variable names are prefixed with `--role-name`, which should usually + match your Ansible role name. +4. A Jinja2 template is generated with values replaced by `{{ variable }}` + expressions. +5. An Ansible defaults YAML file is generated with those variables and the + original values. + +For ERB/Puppet mode: + +1. The same parse/flatten/loop analysis is used. +2. An ERB template is generated with Puppet-style instance variables such as + `<%= @memory_limit %>`. +3. The variables file is written as Puppet Hiera-style data, such as + `php::memory_limit: 256M`. +4. If `--puppet-class` is supplied, that class name is used as the Hiera + namespace while `--role-name` remains the local variable prefix. + +By default, the generated variable data and template are printed to stdout. Use +`--defaults-output` and `--template-output` to write them to files. + +## Jinja2 / Ansible example + +Say you have a `php.ini` file and you are inside an Ansible role with +`defaults/` and `templates/` directories: + +```shell +jinjaturtle php.ini \ + --role-name php \ + --defaults-output defaults/main.yml \ + --template-output templates/php.ini.j2 +``` + +Given a source value such as: + +```ini +memory_limit = 256M +``` + +JinjaTurtle will produce a template value like: + +```jinja2 +memory_limit = {{ php_memory_limit }} +``` + +and defaults data like: + +```yaml +php_memory_limit: 256M +``` + +## ERB / Puppet example + +Use `--template-engine erb` when you want Puppet ERB output: + +```shell +jinjaturtle php.ini \ + --role-name php \ + --template-engine erb \ + --defaults-output data/common.yaml \ + --template-output templates/php.ini.erb +``` + +Given the same source value: + +```ini +memory_limit = 256M +``` + +JinjaTurtle will produce an ERB template value like: + +```erb +memory_limit = <%= @memory_limit %> +``` + +and Hiera-style data like: + +```yaml +php::memory_limit: 256M +``` + +The `--defaults-output` option name is retained for CLI compatibility, but in +ERB mode the file is intended to be Puppet Hiera data rather than Ansible role +defaults. + +JinjaTurtle does **not** generate Puppet classes or `file` resources. A Puppet +module, or another tool such as Enroll, should still declare the class +parameters and call the template, for example with Puppet's `template()` +function. + +## Using `--puppet-class` + +Most direct usage can simply use the same value for the role name and Puppet +class name: + +```shell +jinjaturtle php.ini --role-name php --template-engine erb +``` + +This creates Hiera keys such as: + +```yaml +php::memory_limit: 256M +``` + +and local ERB variables such as: + +```erb +<%= @memory_limit %> +``` + +For generated systems, it can be useful to make `--role-name` more specific +while keeping the Hiera keys under the real Puppet class. For example: + +```shell +jinjaturtle php.ini \ + --role-name php_etc_php_ini \ + --puppet-class php \ + --template-engine erb +``` + +In that case the variable prefix can stay file-specific, while the Hiera data +is still written under `php::...`. ## What sort of config files can it handle? -TOML, YAML, INI, JSON and XML-style config files should be okay. There are always -going to be some edge cases in very complex files that are difficult to work -with, though, so you may still find that you need to tweak the results. +JinjaTurtle supports common structured and semi-structured config formats: -For XML and YAML files, JinjaTurtle will attempt to generate 'for' loops -and lists in the Ansible yaml if the config file looks homogenous enough to -support it. However, if it lacks the confidence in this, it will fall back to -using scalar-style flattened attributes. +- TOML +- YAML +- INI-style files +- JSON +- XML +- Postfix `main.cf` +- systemd unit files, such as `*.service`, `*.socket`, `*.timer`, and related + unit types +- OpenSSH-style config files, including `ssh_config`, `sshd_config`, and common + `*.conf` snippets detected as SSH config -You may need or wish to tidy up the config to suit your needs. +For ambiguous extensions such as `*.conf`, JinjaTurtle uses lightweight content +sniffing. You can always force a handler with `--format`. -The goal here is really to *speed up* converting files into Ansible/Jinja2, -but not necessarily to make it perfect. +For YAML, XML, TOML, INI-style, and other supported structured files, +JinjaTurtle will attempt to generate loops when a repeated structure looks +homogeneous enough. If it is not confident, it falls back to flattened scalar +variables. + +Some very complex files will still need manual cleanup. The goal is to speed up +conversion into Jinja2 or ERB templates, not to guarantee a perfect final module +without review. + +## JSON, quoting, and type preservation + +JinjaTurtle tries to preserve rendered config types. + +For JSON, it uses JSON-aware expressions rather than plain string substitution. +This avoids generating invalid JSON such as: + +```json +{"enabled": True} +``` + +when the correct rendered JSON should be: + +```json +{"enabled": true} +``` + +In Jinja2 mode this uses Ansible-style JSON filters. In ERB mode it emits Ruby +JSON generation where required, for example: + +```erb +<% require 'json' -%> +{ + "enabled": <%= JSON.generate(@enabled) %> +} +``` + +That is expected for JSON ERB templates. ## Can I convert multiple files at once? -Certainly! Pass the folder name instead of a specific file name, and JinjaTurtle -will convert any files it understands in that folder, storing all the various -vars in the destination defaults yaml file, and converting each file into a -Jinja2 template per file type. +Yes. Pass a directory instead of a single file and JinjaTurtle will convert the +files it understands in that directory. -If all the files had the same 'type', there'll be one Jinja2 template. +```shell +jinjaturtle ./config-dir \ + --role-name myrole \ + --defaults-output defaults/main.yml \ + --template-output templates/ +``` -You can also pass `--recursive` to recurse into subfolders. +Use `--recursive` to recurse into subdirectories. -Note: when using 'folder' mode and multiple files of the same type, their vars -will be listed under an 'items' parent key in the yaml, each with an `id` key. -You'll then want to use a `loop` in Ansible later, e.g: +In folder mode, variables for multiple files of the same type are grouped under +an `items`-style structure in the generated YAML so that the resulting templates +can be used with loops in Ansible. + +For example: ```yaml - name: Render configs @@ -93,9 +268,9 @@ sudo dnf upgrade --refresh sudo dnf install jinjaturtle ``` -### From PyPi +### From PyPI -``` +```bash pip install jinjaturtle ``` @@ -103,61 +278,81 @@ pip install jinjaturtle Clone the repo and then run inside the clone: -``` +```bash poetry install ``` ### AppImage -Download the AppImage from the Releases and make it executable, and put it -on your `$PATH`. - -## How to run it - -Say you have a `php.ini` file and you are in a directory structure like an -Ansible role (with subfolders `defaults` and `templates`): - -```shell -jinjaturtle php.ini \ - --role-name php \ - --defaults-output defaults/main.yml \ - --template-output templates/php.ini.j2 -``` +Download the AppImage from the Releases page, make it executable, and put it on +your `$PATH`. ## Full usage info -``` -usage: jinjaturtle [-h] -r ROLE_NAME [-f {json,ini,toml,yaml,xml,postfix,systemd}] [-d DEFAULTS_OUTPUT] [-t TEMPLATE_OUTPUT] config +```text +usage: jinjaturtle [-h] [-r ROLE_NAME] [--recursive] + [-f {ini,json,toml,yaml,xml,postfix,systemd,ssh}] + [-d DEFAULTS_OUTPUT] [-t TEMPLATE_OUTPUT] + [--template-engine {jinja2,erb}] + [--puppet-class PUPPET_CLASS] + config -Convert a config file into Ansible inventory and a Jinja2 template. +Convert a config file into an Ansible defaults file and Jinja2 template. positional arguments: - config Path to the source configuration file. + config Path to a config file OR a folder containing supported + config files. Supported: .toml, .yaml/.yml, .json, + .ini/.cfg/.conf, .xml, ssh_config/sshd_config options: -h, --help show this help message and exit -r, --role-name ROLE_NAME - Ansible role name, used as variable prefix (e.g. cometbft). - -f, --format {ini,json,toml,xml} - Force config format instead of auto-detecting from filename. + Role name / variable prefix. In Jinja2 mode this is + usually the Ansible role name. In ERB mode it is used + as the local variable prefix. Defaults to jinjaturtle. + --recursive When CONFIG is a folder, recurse into subfolders. + -f, --format {ini,json,toml,yaml,xml,postfix,systemd,ssh} + Force config format instead of auto-detecting from + filename. -d, --defaults-output DEFAULTS_OUTPUT - Path to write defaults/main.yml. If omitted, default vars are printed to stdout. + Path to write the generated variable YAML. If omitted, + it is printed to stdout. -t, --template-output TEMPLATE_OUTPUT - Path to write the Jinja2 config template. If omitted, template is printed to stdout. + Path to write the generated config template. If omitted, + it is printed to stdout. + --template-engine {jinja2,erb} + Template syntax to generate. Defaults to jinja2. Use + erb for Puppet templates. + --puppet-class PUPPET_CLASS + Puppet class / Hiera namespace to use with + --template-engine erb. Defaults to --role-name. ``` ## Additional supported formats -JinjaTurtle can also template some common "bespoke" config formats: +JinjaTurtle also templates some common bespoke config formats: - **Postfix main.cf** (`main.cf`) → `--format postfix` - **systemd unit files** (`*.service`, `*.socket`, etc.) → `--format systemd` +- **OpenSSH config** (`ssh_config`, `sshd_config`, and detected snippets) → + `--format ssh` -For ambiguous extensions like `*.conf`, JinjaTurtle uses lightweight content sniffing; you can always force a specific handler via `--format`. +For ambiguous extensions like `*.conf`, JinjaTurtle uses lightweight content +sniffing. You can always force a specific handler with `--format`. +## Relationship with Enroll + +JinjaTurtle can be used directly, but it is also useful as a helper for tools +that generate configuration-management code. + +Enroll can call JinjaTurtle when it is available on the `PATH`. In that setup, +Enroll decides where Puppet, Ansible, or Salt files belong, while JinjaTurtle +concentrates on converting harvested config files into templates plus variable +data. ## Found a bug, have a suggestion? -You can e-mail me (see the pyproject.toml for details) or contact me on the Fediverse: +You can e-mail me; see `pyproject.toml` for details. You can also contact me on +the Fediverse: https://goto.mig5.net/@mig5 diff --git a/debian/changelog b/debian/changelog index 44d4e28..1950637 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +jinjaturtle (0.5.5) unstable; urgency=medium + + * erb support + + -- Miguel Jacq Sat, 20 Jun 2026 18:27:00 +1000 + jinjaturtle (0.5.4) unstable; urgency=medium * Make templates more faithful to the original file in terms of indentation, newlines, no deserialisation of things like < or >. diff --git a/pyproject.toml b/pyproject.toml index 1279f81..cbc30c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "jinjaturtle" -version = "0.5.4" +version = "0.5.5" description = "Convert config files into Ansible defaults and Jinja2 templates." authors = ["Miguel Jacq "] license = "GPL-3.0-or-later" diff --git a/rpm/jinjaturtle.spec b/rpm/jinjaturtle.spec index a303b15..f354c18 100644 --- a/rpm/jinjaturtle.spec +++ b/rpm/jinjaturtle.spec @@ -1,4 +1,4 @@ -%global upstream_version 0.5.4 +%global upstream_version 0.5.5 Name: jinjaturtle Version: %{upstream_version} @@ -43,6 +43,8 @@ Convert config files into Ansible defaults and Jinja2 templates. %changelog * Sat Jun 20 2026 Miguel Jacq - %{version}-%{release} +- erb support +* Sat Jun 20 2026 Miguel Jacq - %{version}-%{release} - Make templates more faithful to the original file in terms of indentation, newlines, no deserialisation of things like < or >. - More test coverage * Fri Jun 19 2026 Miguel Jacq - %{version}-%{release} diff --git a/src/jinjaturtle/cli.py b/src/jinjaturtle/cli.py index 82b6775..7658bfb 100644 --- a/src/jinjaturtle/cli.py +++ b/src/jinjaturtle/cli.py @@ -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 diff --git a/src/jinjaturtle/core.py b/src/jinjaturtle/core.py index ff6a586..673aad0 100644 --- a/src/jinjaturtle/core.py +++ b/src/jinjaturtle/core.py @@ -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 diff --git a/src/jinjaturtle/erb.py b/src/jinjaturtle/erb.py new file mode 100644 index 0000000..445ec51 --- /dev/null +++ b/src/jinjaturtle/erb.py @@ -0,0 +1,218 @@ +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.endswith(("-", "+")): + stmt = stmt[:-1].rstrip() + + 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) diff --git a/src/jinjaturtle/handlers/ini.py b/src/jinjaturtle/handlers/ini.py index ad92b72..1aa5e22 100644 --- a/src/jinjaturtle/handlers/ini.py +++ b/src/jinjaturtle/handlers/ini.py @@ -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 diff --git a/src/jinjaturtle/handlers/json.py b/src/jinjaturtle/handlers/json.py index 9d763a4..064535a 100644 --- a/src/jinjaturtle/handlers/json.py +++ b/src/jinjaturtle/handlers/json.py @@ -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}]" ) diff --git a/src/jinjaturtle/handlers/postfix.py b/src/jinjaturtle/handlers/postfix.py index 65f6be9..e2e442a 100644 --- a/src/jinjaturtle/handlers/postfix.py +++ b/src/jinjaturtle/handlers/postfix.py @@ -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) diff --git a/src/jinjaturtle/handlers/ssh.py b/src/jinjaturtle/handlers/ssh.py index bedaa2a..a6c0d2f 100644 --- a/src/jinjaturtle/handlers/ssh.py +++ b/src/jinjaturtle/handlers/ssh.py @@ -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}" diff --git a/src/jinjaturtle/handlers/systemd.py b/src/jinjaturtle/handlers/systemd.py index 044fd86..8118808 100644 --- a/src/jinjaturtle/handlers/systemd.py +++ b/src/jinjaturtle/handlers/systemd.py @@ -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) diff --git a/src/jinjaturtle/handlers/toml.py b/src/jinjaturtle/handlers/toml.py index ba4bbb4..15d5a2a 100644 --- a/src/jinjaturtle/handlers/toml.py +++ b/src/jinjaturtle/handlers/toml.py @@ -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 diff --git a/src/jinjaturtle/handlers/xml.py b/src/jinjaturtle/handlers/xml.py index fed6aba..51c7a73 100644 --- a/src/jinjaturtle/handlers/xml.py +++ b/src/jinjaturtle/handlers/xml.py @@ -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 with {% endif %} elif "