Improvements
Some checks failed
CI / test (push) Failing after 33s
Lint / test (push) Successful in 23s
Trivy / test (push) Successful in 22s

* Preserve comments in Jinja2 templates
 * Handle truthy/falsy statements better
 * Handle params that have an empty value (php.ini is notorious)
 * Add indentation to yaml and also starting --- so yamllint passes
This commit is contained in:
Miguel Jacq 2025-11-25 16:35:18 +11:00
parent 2be1e9e895
commit f992da47ee
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
5 changed files with 396 additions and 13 deletions

3
.gitignore vendored
View file

@ -4,3 +4,6 @@ __pycache__
.pytest_cache .pytest_cache
dist dist
.coverage .coverage
*.yml
*.j2
*.toml

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "jinjaturtle" name = "jinjaturtle"
version = "0.1.0" version = "0.1.1"
description = "Convert config files into Ansible defaults and Jinja2 templates." description = "Convert config files into Ansible defaults and Jinja2 templates."
authors = ["Miguel Jacq <mig@mig5.net>"] authors = ["Miguel Jacq <mig@mig5.net>"]
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"

View file

@ -54,7 +54,10 @@ def _main(argv: list[str] | None = None) -> int:
fmt, parsed = parse_config(config_path, args.format) fmt, parsed = parse_config(config_path, args.format)
flat_items = flatten_config(fmt, parsed) flat_items = flatten_config(fmt, parsed)
defaults_yaml = generate_defaults_yaml(args.role_name, flat_items) defaults_yaml = generate_defaults_yaml(args.role_name, flat_items)
template_str = generate_template(fmt, parsed, args.role_name) config_text = config_path.read_text(encoding="utf-8")
template_str = generate_template(
fmt, parsed, args.role_name, original_text=config_text
)
if args.defaults_output: if args.defaults_output:
Path(args.defaults_output).write_text(defaults_yaml, encoding="utf-8") Path(args.defaults_output).write_text(defaults_yaml, encoding="utf-8")

View file

@ -15,6 +15,41 @@ except ModuleNotFoundError: # pragma: no cover
tomllib = None # type: ignore tomllib = None # type: ignore
class QuotedString(str):
"""Marker type for strings that must be double-quoted in YAML output."""
pass
class _TurtleDumper(yaml.SafeDumper):
"""Custom YAML dumper that always double-quotes QuotedString values."""
pass
def _quoted_str_representer(dumper: yaml.SafeDumper, data: QuotedString):
return dumper.represent_scalar("tag:yaml.org,2002:str", str(data), style='"')
_TurtleDumper.add_representer(QuotedString, _quoted_str_representer)
def _normalize_default_value(value: Any) -> Any:
"""
Ensure that 'true' / 'false' end up as quoted strings in YAML, not booleans.
- bool -> QuotedString("true"/"false")
- "true"/"false" (any case) -> QuotedString(original_text)
- everything else -> unchanged
"""
if isinstance(value, bool):
# YAML booleans are lower-case; we keep them as strings.
return QuotedString("true" if value else "false")
if isinstance(value, str) and value.lower() in {"true", "false"}:
return QuotedString(value)
return value
def detect_format(path: Path, explicit: str | None = None) -> str: def detect_format(path: Path, explicit: str | None = None) -> str:
""" """
Determine config format (toml vs ini-ish) from argument or filename. Determine config format (toml vs ini-ish) from argument or filename.
@ -130,22 +165,49 @@ def make_var_name(role_prefix: str, path: Iterable[str]) -> str:
return role_prefix return role_prefix
def _split_inline_comment(text: str, comment_chars: set[str]) -> tuple[str, str]:
"""
Split 'value # comment' into (value_part, comment_part), where
comment_part starts at the first unquoted comment character.
comment_chars is e.g. {'#'} for TOML, {'#', ';'} for INI.
"""
in_single = False
in_double = False
for i, ch in enumerate(text):
if ch == "'" and not in_double:
in_single = not in_single
elif ch == '"' and not in_single:
in_double = not in_double
elif ch in comment_chars and not in_single and not in_double:
return text[:i], text[i:]
return text, ""
def generate_defaults_yaml( def generate_defaults_yaml(
role_prefix: str, flat_items: list[tuple[tuple[str, ...], Any]] role_prefix: str,
flat_items: list[tuple[tuple[str, ...], Any]],
) -> str: ) -> str:
""" """
Create YAML for defaults/main.yml from flattened items. Create YAML for defaults/main.yml from flattened items.
Boolean/boolean-like values ("true"/"false") are forced to be *strings*
and double-quoted in the resulting YAML so that Ansible does not coerce
them back into Python booleans.
""" """
defaults: dict[str, Any] = {} defaults: dict[str, Any] = {}
for path, value in flat_items: for path, value in flat_items:
var_name = make_var_name(role_prefix, path) var_name = make_var_name(role_prefix, path)
defaults[var_name] = value defaults[var_name] = _normalize_default_value(value)
return yaml.safe_dump( return yaml.dump(
defaults, defaults,
Dumper=_TurtleDumper,
sort_keys=True, sort_keys=True,
default_flow_style=False, default_flow_style=False,
allow_unicode=True, allow_unicode=True,
explicit_start=True,
indent=2,
) )
@ -223,10 +285,228 @@ def _generate_ini_template(role_prefix: str, parser: configparser.ConfigParser)
return "\n".join(lines).rstrip() + "\n" return "\n".join(lines).rstrip() + "\n"
def generate_template(fmt: str, parsed: Any, role_prefix: str) -> str: def _generate_ini_template_from_text(role_prefix: str, text: str) -> str:
""" """
Dispatch to the appropriate template generator. Generate a Jinja2 template for an INI/php.ini-style file, preserving
comments, blank lines, and section headers by patching values in-place.
""" """
lines = text.splitlines(keepends=True)
current_section: str | None = None
out_lines: list[str] = []
for raw_line in lines:
line = raw_line
stripped = line.lstrip()
# Blank or pure comment: keep as-is
if not stripped or stripped[0] in {"#", ";"}:
out_lines.append(raw_line)
continue
# Section header
if stripped.startswith("[") and "]" in stripped:
header_inner = stripped[1 : stripped.index("]")]
current_section = header_inner.strip()
out_lines.append(raw_line)
continue
# Work without newline so we can re-attach it exactly
newline = ""
content = raw_line
if content.endswith("\r\n"):
newline = "\r\n"
content = content[:-2]
elif content.endswith("\n"):
newline = content[-1]
content = content[:-1]
eq_index = content.find("=")
if eq_index == -1:
# Not a simple key=value line: leave untouched
out_lines.append(raw_line)
continue
before_eq = content[:eq_index]
after_eq = content[eq_index + 1 :]
key = before_eq.strip()
if not key:
out_lines.append(raw_line)
continue
# Whitespace after '='
value_ws_len = len(after_eq) - len(after_eq.lstrip(" \t"))
leading_ws = after_eq[:value_ws_len]
value_and_comment = after_eq[value_ws_len:]
value_part, comment_part = _split_inline_comment(value_and_comment, {"#", ";"})
raw_value = value_part.strip()
path = (key,) if current_section is None else (current_section, key)
var_name = make_var_name(role_prefix, path)
# Was the original value quoted?
use_quotes = (
len(raw_value) >= 2
and raw_value[0] == raw_value[-1]
and raw_value[0] in {'"', "'"}
)
if use_quotes:
quote_char = raw_value[0]
replacement_value = f"{quote_char}{{{{ {var_name} }}}}{quote_char}"
else:
replacement_value = f"{{{{ {var_name} }}}}"
new_content = before_eq + "=" + leading_ws + replacement_value + comment_part
out_lines.append(new_content + newline)
return "".join(out_lines)
def _generate_toml_template_from_text(role_prefix: str, text: str) -> str:
"""
Generate a Jinja2 template for a TOML file, preserving comments,
blank lines, and table headers by patching values in-place.
Handles inline tables like:
temp_targets = { cpu = 79.5, case = 72.0 }
by mapping them to:
temp_targets = { cpu = {{ prefix_database_temp_targets_cpu }},
case = {{ prefix_database_temp_targets_case }} }
"""
lines = text.splitlines(keepends=True)
current_table: tuple[str, ...] = ()
out_lines: list[str] = []
for raw_line in lines:
line = raw_line
stripped = line.lstrip()
# Blank or pure comment
if not stripped or stripped.startswith("#"):
out_lines.append(raw_line)
continue
# Table header: [server] or [server.tls] or [[array.of.tables]]
if stripped.startswith("[") and "]" in stripped:
header = stripped
first_bracket = header.find("[")
closing_bracket = header.find("]", first_bracket + 1)
if first_bracket != -1 and closing_bracket != -1:
inner = header[first_bracket + 1 : closing_bracket].strip()
inner = inner.strip("[]") # handle [[table]] as well
parts = [p.strip() for p in inner.split(".") if p.strip()]
current_table = tuple(parts)
out_lines.append(raw_line)
continue
# Try key = value
newline = ""
content = raw_line
if content.endswith("\r\n"):
newline = "\r\n"
content = content[:-2]
elif content.endswith("\n"):
newline = content[-1]
content = content[:-1]
eq_index = content.find("=")
if eq_index == -1:
out_lines.append(raw_line)
continue
before_eq = content[:eq_index]
after_eq = content[eq_index + 1 :]
key = before_eq.strip()
if not key:
out_lines.append(raw_line)
continue
# Whitespace after '='
value_ws_len = len(after_eq) - len(after_eq.lstrip(" \t"))
leading_ws = after_eq[:value_ws_len]
value_and_comment = after_eq[value_ws_len:]
value_part, comment_part = _split_inline_comment(value_and_comment, {"#"})
raw_value = value_part.strip()
# Path for this key (table + key)
path = current_table + (key,)
# Special case: inline table
if (
raw_value.startswith("{")
and raw_value.endswith("}")
and tomllib is not None
):
try:
# Parse the inline table as a tiny TOML document
mini_source = "table = " + raw_value + "\n"
mini_data = tomllib.loads(mini_source)["table"]
except Exception:
mini_data = None
if isinstance(mini_data, dict):
inner_bits: list[str] = []
for sub_key, sub_val in mini_data.items():
nested_path = path + (sub_key,)
nested_var = make_var_name(role_prefix, nested_path)
if isinstance(sub_val, str):
inner_bits.append(f'{sub_key} = "{{{{ {nested_var} }}}}"')
else:
inner_bits.append(f"{sub_key} = {{{{ {nested_var} }}}}")
replacement_value = "{ " + ", ".join(inner_bits) + " }"
new_content = (
before_eq + "=" + leading_ws + replacement_value + comment_part
)
out_lines.append(new_content + newline)
continue
# If parsing fails, fall through to normal handling
# Normal scalar value handling (including bools, numbers, strings)
var_name = make_var_name(role_prefix, path)
use_quotes = (
len(raw_value) >= 2
and raw_value[0] == raw_value[-1]
and raw_value[0] in {'"', "'"}
)
if use_quotes:
quote_char = raw_value[0]
replacement_value = f"{quote_char}{{{{ {var_name} }}}}{quote_char}"
else:
replacement_value = f"{{{{ {var_name} }}}}"
new_content = before_eq + "=" + leading_ws + replacement_value + comment_part
out_lines.append(new_content + newline)
return "".join(out_lines)
def generate_template(
fmt: str,
parsed: Any,
role_prefix: str,
original_text: str | None = None,
) -> str:
"""
Generate a Jinja2 template for the config.
If original_text is provided, comments and blank lines are preserved by
patching values in-place. Otherwise we fall back to reconstructing from
the parsed structure (no comments).
"""
if original_text is not None:
if fmt == "toml":
return _generate_toml_template_from_text(role_prefix, original_text)
if fmt == "ini":
return _generate_ini_template_from_text(role_prefix, original_text)
raise ValueError(f"Unsupported format: {fmt}")
# Fallback: previous behaviour (no comments preserved)
if fmt == "toml": if fmt == "toml":
if not isinstance(parsed, dict): if not isinstance(parsed, dict):
raise TypeError("TOML parser result must be a dict") raise TypeError("TOML parser result must be a dict")

View file

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
import configparser
import pytest import pytest
import yaml import yaml
@ -69,7 +69,7 @@ def test_toml_sample_roundtrip():
assert fmt == "toml" assert fmt == "toml"
flat_items = flatten_config(fmt, parsed) flat_items = flatten_config(fmt, parsed)
assert flat_items, "Expected at least one flattened item from TOML sample" assert flat_items
defaults_yaml = generate_defaults_yaml("jinjaturtle", flat_items) defaults_yaml = generate_defaults_yaml("jinjaturtle", flat_items)
defaults = yaml.safe_load(defaults_yaml) defaults = yaml.safe_load(defaults_yaml)
@ -84,10 +84,16 @@ def test_toml_sample_roundtrip():
assert key == key.lower() assert key == key.lower()
assert " " not in key assert " " not in key
# template generation # template generation **now with original_text**
template = generate_template(fmt, parsed, "jinjaturtle") original_text = toml_path.read_text(encoding="utf-8")
template = generate_template(
fmt, parsed, "jinjaturtle", original_text=original_text
)
assert isinstance(template, str) assert isinstance(template, str)
assert template.strip(), "Template for TOML sample should not be empty" assert template.strip()
# comments from the original file should now be preserved
assert "# This is a TOML document" in template
# each default variable name should appear in the template as a Jinja placeholder # each default variable name should appear in the template as a Jinja placeholder
for var_name in defaults: for var_name in defaults:
@ -120,7 +126,9 @@ def test_ini_php_sample_roundtrip():
assert " " not in key assert " " not in key
# template generation # template generation
template = generate_template(fmt, parsed, "php") original_text = ini_path.read_text(encoding="utf-8")
template = generate_template(fmt, parsed, "php", original_text=original_text)
assert "; About this file" in template
assert isinstance(template, str) assert isinstance(template, str)
assert template.strip(), "Template for php.ini sample should not be empty" assert template.strip(), "Template for php.ini sample should not be empty"
@ -189,3 +197,92 @@ def test_generate_template_type_and_format_errors():
# unsupported format # unsupported format
with pytest.raises(ValueError): with pytest.raises(ValueError):
generate_template("yaml", parsed=None, role_prefix="role") generate_template("yaml", parsed=None, role_prefix="role")
# unsupported format even when original_text is provided
with pytest.raises(ValueError):
generate_template(
"yaml",
parsed=None,
role_prefix="role",
original_text="foo=bar",
)
def test_normalize_default_value_true_false_strings():
# 'true'/'false' strings should be preserved as strings and double-quoted in YAML.
flat_items = [
(("section", "foo"), "true"),
(("section", "bar"), "FALSE"),
]
defaults_yaml = generate_defaults_yaml("role", flat_items)
data = yaml.safe_load(defaults_yaml)
assert data["role_section_foo"] == "true"
assert data["role_section_bar"] == "FALSE"
def test_split_inline_comment_handles_quoted_hash():
# The '#' inside quotes should not start a comment; the one outside should.
text = " 'foo # not comment' # real"
value, comment = core._split_inline_comment(text, {"#"})
assert "not comment" in value
assert comment.strip() == "# real"
def test_generate_template_fallback_toml_and_ini():
# When original_text is not provided, generate_template should use the
# older fallback generators based on the parsed structures.
parsed_toml = {
"title": "Example",
"server": {"port": 8080, "host": "127.0.0.1"},
"logging": {
"file": {"path": "/tmp/app.log"}
}, # nested table to hit recursive walk
}
tmpl_toml = generate_template("toml", parsed=parsed_toml, role_prefix="role")
assert "[server]" in tmpl_toml
assert "role_server_port" in tmpl_toml
assert "[logging]" in tmpl_toml or "[logging.file]" in tmpl_toml
parser = configparser.ConfigParser()
# foo is quoted in the INI text to hit the "preserve quotes" branch
parser["section"] = {"foo": '"bar"', "num": "42"}
tmpl_ini = generate_template("ini", parsed=parser, role_prefix="role")
assert "[section]" in tmpl_ini
assert "role_section_foo" in tmpl_ini
assert '"{{ role_section_foo }}"' in tmpl_ini # came from quoted INI value
def test_generate_ini_template_from_text_edge_cases():
# Cover CRLF newlines, lines without '=', and lines with no key before '='.
text = "[section]\r\nkey=value\r\nnoequals\r\n = bare\r\n"
tmpl = core._generate_ini_template_from_text("role", text)
# We don't care about exact formatting here, just that it runs and
# produces some reasonable output.
assert "[section]" in tmpl
assert "role_section_key" in tmpl
# The "noequals" line should be preserved as-is.
assert "noequals" in tmpl
# The " = bare" line has no key and should be left untouched.
assert " = bare" in tmpl
def test_generate_toml_template_from_text_edge_cases():
# Cover CRLF newlines, lines without '=', empty keys, and inline tables
# that both parse successfully and fail parsing.
text = (
"# comment\r\n"
"[table]\r\n"
"noequals\r\n"
" = 42\r\n"
'inline_good = { name = "abc", value = 1 }\r\n'
"inline_bad = { invalid = }\r\n"
)
tmpl = core._generate_toml_template_from_text("role", text)
# The good inline table should expand into two separate variables.
assert "role_table_inline_good_name" in tmpl
assert "role_table_inline_good_value" in tmpl
# The bad inline table should fall back to scalar handling.
assert "role_table_inline_bad" in tmpl
# Ensure the lines without '=' / empty key were handled without exploding.
assert "[table]" in tmpl
assert "noequals" in tmpl