Compare commits
No commits in common. "5f81ac33955a631a717aabd7edccd7bcff132adb" and "409824a3b5c0f52fbb964267d1f18c30ba6a5ca0" have entirely different histories.
5f81ac3395
...
409824a3b5
6 changed files with 41 additions and 125 deletions
|
|
@ -1,13 +1,13 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "jinjaturtle"
|
name = "jinjaturtle"
|
||||||
version = "0.1.3"
|
version = "0.1.2"
|
||||||
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"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
packages = [{ include = "jinjaturtle", from = "src" }]
|
packages = [{ include = "jinjaturtle", from = "src" }]
|
||||||
|
|
||||||
keywords = ["ansible", "jinja2", "config", "toml", "ini", "yaml", "json", "devops"]
|
keywords = ["ansible", "jinja2", "config", "toml", "ini", "devops"]
|
||||||
|
|
||||||
homepage = "https://git.mig5.net/mig5/jinjaturtle"
|
homepage = "https://git.mig5.net/mig5/jinjaturtle"
|
||||||
repository = "https://git.mig5.net/mig5/jinjaturtle"
|
repository = "https://git.mig5.net/mig5/jinjaturtle"
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
||||||
ap.add_argument(
|
ap.add_argument(
|
||||||
"-f",
|
"-f",
|
||||||
"--format",
|
"--format",
|
||||||
choices=["ini", "json", "toml", "yaml"],
|
choices=["ini", "toml"],
|
||||||
help="Force config format instead of auto-detecting from filename.",
|
help="Force config format instead of auto-detecting from filename.",
|
||||||
)
|
)
|
||||||
ap.add_argument(
|
ap.add_argument(
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@ from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ruamel.yaml import YAML as RuamelYAML # for comment-preserving YAML
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
RuamelYAML = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import tomllib # Python 3.11+
|
import tomllib # Python 3.11+
|
||||||
except ModuleNotFoundError: # pragma: no cover
|
except ModuleNotFoundError: # pragma: no cover
|
||||||
|
|
@ -21,14 +26,6 @@ class QuotedString(str):
|
||||||
pass
|
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):
|
class _TurtleDumper(yaml.SafeDumper):
|
||||||
"""Custom YAML dumper that always double-quotes QuotedString values."""
|
"""Custom YAML dumper that always double-quotes QuotedString values."""
|
||||||
|
|
||||||
|
|
@ -40,8 +37,6 @@ def _quoted_str_representer(dumper: yaml.SafeDumper, data: QuotedString):
|
||||||
|
|
||||||
|
|
||||||
_TurtleDumper.add_representer(QuotedString, _quoted_str_representer)
|
_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:
|
def detect_format(path: Path, explicit: str | None = None) -> str:
|
||||||
|
|
@ -81,6 +76,14 @@ def parse_config(path: Path, fmt: str | None = None) -> tuple[str, Any]:
|
||||||
|
|
||||||
if fmt == "yaml":
|
if fmt == "yaml":
|
||||||
text = path.read_text(encoding="utf-8")
|
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
|
return fmt, data
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
---
|
|
||||||
# Top comment
|
|
||||||
foo: "bar"
|
|
||||||
|
|
||||||
blah:
|
|
||||||
- something
|
|
||||||
- else
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
{
|
|
||||||
"foo": "bar",
|
|
||||||
"nested": {
|
|
||||||
"a": 1,
|
|
||||||
"b": true
|
|
||||||
},
|
|
||||||
"list": [
|
|
||||||
10,
|
|
||||||
20
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -200,10 +200,6 @@ def test_generate_template_type_and_format_errors():
|
||||||
with pytest.raises(TypeError):
|
with pytest.raises(TypeError):
|
||||||
generate_template("yaml", parsed=None, role_prefix="role")
|
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
|
# unsupported format, no original_text
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
generate_template("bogusfmt", parsed=None, role_prefix="role")
|
generate_template("bogusfmt", parsed=None, role_prefix="role")
|
||||||
|
|
@ -299,11 +295,18 @@ def test_generate_toml_template_from_text_edge_cases():
|
||||||
|
|
||||||
|
|
||||||
def test_yaml_roundtrip_with_list_and_comment(tmp_path: Path):
|
def test_yaml_roundtrip_with_list_and_comment(tmp_path: Path):
|
||||||
yaml_path = SAMPLES_DIR / "bar.yaml"
|
yaml_text = """
|
||||||
assert yaml_path.is_file(), f"Missing sample YAML file: {yaml_path}"
|
# Top comment
|
||||||
|
foo: "bar"
|
||||||
|
|
||||||
fmt, parsed = parse_config(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(cfg_path)
|
||||||
assert fmt == "yaml"
|
assert fmt == "yaml"
|
||||||
|
|
||||||
flat_items = flatten_config(fmt, parsed)
|
flat_items = flatten_config(fmt, parsed)
|
||||||
|
|
@ -316,7 +319,7 @@ def test_yaml_roundtrip_with_list_and_comment(tmp_path: Path):
|
||||||
assert defaults["foobar_blah_1"] == "else"
|
assert defaults["foobar_blah_1"] == "else"
|
||||||
|
|
||||||
# Template generation (preserving comments)
|
# Template generation (preserving comments)
|
||||||
original_text = yaml_path.read_text(encoding="utf-8")
|
original_text = cfg_path.read_text(encoding="utf-8")
|
||||||
template = generate_template(fmt, parsed, "foobar", original_text=original_text)
|
template = generate_template(fmt, parsed, "foobar", original_text=original_text)
|
||||||
|
|
||||||
# Comment preserved
|
# Comment preserved
|
||||||
|
|
@ -334,10 +337,20 @@ def test_yaml_roundtrip_with_list_and_comment(tmp_path: Path):
|
||||||
|
|
||||||
|
|
||||||
def test_json_roundtrip(tmp_path: Path):
|
def test_json_roundtrip(tmp_path: Path):
|
||||||
json_path = SAMPLES_DIR / "foo.json"
|
json_text = """
|
||||||
assert json_path.is_file(), f"Missing sample JSON file: {json_path}"
|
{
|
||||||
|
"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")
|
||||||
|
|
||||||
fmt, parsed = parse_config(json_path)
|
fmt, parsed = parse_config(cfg_path)
|
||||||
assert fmt == "json"
|
assert fmt == "json"
|
||||||
|
|
||||||
flat_items = flatten_config(fmt, parsed)
|
flat_items = flatten_config(fmt, parsed)
|
||||||
|
|
@ -360,85 +373,3 @@ def test_json_roundtrip(tmp_path: Path):
|
||||||
assert "foobar_nested_b" in template
|
assert "foobar_nested_b" in template
|
||||||
assert "foobar_list_0" in template
|
assert "foobar_list_0" in template
|
||||||
assert "foobar_list_1" 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
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue