Compare commits

...

7 commits

Author SHA1 Message Date
5f81ac3395
More tests for edge cases (100% coverage reached)
All checks were successful
CI / test (push) Successful in 39s
Lint / test (push) Successful in 25s
Trivy / test (push) Successful in 23s
2025-11-26 15:41:13 +11:00
ad7ec81078
Remove ruamel stuff 2025-11-26 15:40:38 +11:00
11a5ac690f
Remove ruamel dependency 2025-11-26 15:40:25 +11:00
8a90b24a00
Fix for when ruamel is used 2025-11-26 15:31:48 +11:00
9b3585ae89
Add ruamel as a dep 2025-11-26 15:29:19 +11:00
838e3f0010
Move yaml/json sample configs into the samples dir like the other ones 2025-11-26 15:29:09 +11:00
8425154481
Add json and yaml to the -f arg 2025-11-26 15:25:06 +11:00
6 changed files with 125 additions and 41 deletions

View file

@ -1,13 +1,13 @@
[tool.poetry]
name = "jinjaturtle"
version = "0.1.2"
version = "0.1.3"
description = "Convert config files into Ansible defaults and Jinja2 templates."
authors = ["Miguel Jacq <mig@mig5.net>"]
license = "GPL-3.0-or-later"
readme = "README.md"
packages = [{ include = "jinjaturtle", from = "src" }]
keywords = ["ansible", "jinja2", "config", "toml", "ini", "devops"]
keywords = ["ansible", "jinja2", "config", "toml", "ini", "yaml", "json", "devops"]
homepage = "https://git.mig5.net/mig5/jinjaturtle"
repository = "https://git.mig5.net/mig5/jinjaturtle"

View file

@ -30,7 +30,7 @@ def _build_arg_parser() -> argparse.ArgumentParser:
ap.add_argument(
"-f",
"--format",
choices=["ini", "toml"],
choices=["ini", "json", "toml", "yaml"],
help="Force config format instead of auto-detecting from filename.",
)
ap.add_argument(

View file

@ -6,11 +6,6 @@ from pathlib import Path
from typing import Any, Iterable
import yaml
try:
from ruamel.yaml import YAML as RuamelYAML # for comment-preserving YAML
except ImportError: # pragma: no cover
RuamelYAML = None
try:
import tomllib # Python 3.11+
except ModuleNotFoundError: # pragma: no cover
@ -26,6 +21,14 @@ class QuotedString(str):
pass
def _fallback_str_representer(dumper: yaml.SafeDumper, data: Any):
"""
Fallback for objects the dumper doesn't know about. Represent them as
plain strings.
"""
return dumper.represent_scalar("tag:yaml.org,2002:str", str(data))
class _TurtleDumper(yaml.SafeDumper):
"""Custom YAML dumper that always double-quotes QuotedString values."""
@ -37,6 +40,8 @@ def _quoted_str_representer(dumper: yaml.SafeDumper, data: QuotedString):
_TurtleDumper.add_representer(QuotedString, _quoted_str_representer)
# Use our fallback for any unknown object types
_TurtleDumper.add_representer(None, _fallback_str_representer)
def detect_format(path: Path, explicit: str | None = None) -> str:
@ -76,15 +81,7 @@ def parse_config(path: Path, fmt: str | None = None) -> tuple[str, Any]:
if fmt == "yaml":
text = path.read_text(encoding="utf-8")
if RuamelYAML is not None:
# ruamel.yaml preserves comments; we'll reuse them in template gen
y = RuamelYAML()
y.preserve_quotes = True
data = y.load(text) or {}
else:
# Fallback: PyYAML (drops comments in parsed structure, but we still
# have the original text for comment-preserving template generation).
data = yaml.safe_load(text) or {}
data = yaml.safe_load(text) or {}
return fmt, data
if fmt == "json":

7
tests/samples/bar.yaml Normal file
View file

@ -0,0 +1,7 @@
---
# Top comment
foo: "bar"
blah:
- something
- else

11
tests/samples/foo.json Normal file
View file

@ -0,0 +1,11 @@
{
"foo": "bar",
"nested": {
"a": 1,
"b": true
},
"list": [
10,
20
]
}

View file

@ -200,6 +200,10 @@ def test_generate_template_type_and_format_errors():
with pytest.raises(TypeError):
generate_template("yaml", parsed=None, role_prefix="role")
# wrong type for JSON
with pytest.raises(TypeError):
generate_template("json", parsed=None, role_prefix="role")
# unsupported format, no original_text
with pytest.raises(ValueError):
generate_template("bogusfmt", parsed=None, role_prefix="role")
@ -295,18 +299,11 @@ def test_generate_toml_template_from_text_edge_cases():
def test_yaml_roundtrip_with_list_and_comment(tmp_path: Path):
yaml_text = """
# Top comment
foo: "bar"
yaml_path = SAMPLES_DIR / "bar.yaml"
assert yaml_path.is_file(), f"Missing sample YAML file: {yaml_path}"
blah:
- something
- else
"""
cfg_path = tmp_path / "config.yaml"
cfg_path.write_text(textwrap.dedent(yaml_text), encoding="utf-8")
fmt, parsed = parse_config(yaml_path)
fmt, parsed = parse_config(cfg_path)
assert fmt == "yaml"
flat_items = flatten_config(fmt, parsed)
@ -319,7 +316,7 @@ def test_yaml_roundtrip_with_list_and_comment(tmp_path: Path):
assert defaults["foobar_blah_1"] == "else"
# Template generation (preserving comments)
original_text = cfg_path.read_text(encoding="utf-8")
original_text = yaml_path.read_text(encoding="utf-8")
template = generate_template(fmt, parsed, "foobar", original_text=original_text)
# Comment preserved
@ -337,20 +334,10 @@ def test_yaml_roundtrip_with_list_and_comment(tmp_path: Path):
def test_json_roundtrip(tmp_path: Path):
json_text = """
{
"foo": "bar",
"nested": {
"a": 1,
"b": true
},
"list": [10, 20]
}
"""
cfg_path = tmp_path / "config.json"
cfg_path.write_text(textwrap.dedent(json_text), encoding="utf-8")
json_path = SAMPLES_DIR / "foo.json"
assert json_path.is_file(), f"Missing sample JSON file: {json_path}"
fmt, parsed = parse_config(cfg_path)
fmt, parsed = parse_config(json_path)
assert fmt == "json"
flat_items = flatten_config(fmt, parsed)
@ -373,3 +360,85 @@ def test_json_roundtrip(tmp_path: Path):
assert "foobar_nested_b" in template
assert "foobar_list_0" in template
assert "foobar_list_1" in template
def test_generate_yaml_template_from_text_edge_cases():
"""
Exercise YAML text edge cases:
- indentation dedent (stack pop)
- empty key before ':'
- quoted and unquoted list items
"""
text = textwrap.dedent(
"""
root:
child: 1
other: 2
: 3
list:
- "quoted"
- unquoted
"""
)
tmpl = core._generate_yaml_template_from_text("role", text)
# Dedent from "root -> child" back to "other" exercises the stack-pop path.
# Just check the expected variable names appear.
assert "role_root_child" in tmpl
assert "role_other" in tmpl
# The weird " : 3" line has no key and should be left untouched.
assert " : 3" in tmpl
# The list should generate indexed variables for each item.
# First item is quoted (use_quotes=True), second is unquoted.
assert "role_list_0" in tmpl
assert "role_list_1" in tmpl
def test_generate_template_yaml_structural_fallback():
"""
When original_text is not provided for YAML, generate_template should use
the structural fallback path (yaml.safe_dump + _generate_yaml_template_from_text).
"""
parsed = {"outer": {"inner": "val"}}
tmpl = generate_template("yaml", parsed=parsed, role_prefix="role")
# We don't care about exact formatting, just that the expected variable
# name shows up, proving we went through the structural path.
assert "role_outer_inner" in tmpl
def test_generate_template_json_type_error():
"""
Wrong type for JSON in generate_template should raise TypeError.
"""
with pytest.raises(TypeError):
generate_template("json", parsed="not a dict", role_prefix="role")
def test_fallback_str_representer_for_unknown_type():
"""
Ensure that the _fallback_str_representer is used for objects that
PyYAML doesn't know how to represent.
"""
class Weird:
def __str__(self) -> str:
return "weird-value"
data = {"foo": Weird()}
# This will exercise _fallback_str_representer, because Weird has no
# dedicated representer and _TurtleDumper registers our fallback for None.
dumped = yaml.dump(
data,
Dumper=core._TurtleDumper,
sort_keys=False,
default_flow_style=False,
)
# It should serialize without error, and the string form should appear.
assert "weird-value" in dumped