diff --git a/pyproject.toml b/pyproject.toml index 977325b..bd3db91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [tool.poetry] name = "jinjaturtle" -version = "0.1.3" +version = "0.1.2" description = "Convert config files into Ansible defaults and Jinja2 templates." authors = ["Miguel Jacq "] license = "GPL-3.0-or-later" readme = "README.md" 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" repository = "https://git.mig5.net/mig5/jinjaturtle" diff --git a/src/jinjaturtle/cli.py b/src/jinjaturtle/cli.py index 9b13502..582a920 100644 --- a/src/jinjaturtle/cli.py +++ b/src/jinjaturtle/cli.py @@ -30,7 +30,7 @@ def _build_arg_parser() -> argparse.ArgumentParser: ap.add_argument( "-f", "--format", - choices=["ini", "json", "toml", "yaml"], + choices=["ini", "toml"], help="Force config format instead of auto-detecting from filename.", ) ap.add_argument( diff --git a/src/jinjaturtle/core.py b/src/jinjaturtle/core.py index bc5f822..b2ea3d2 100644 --- a/src/jinjaturtle/core.py +++ b/src/jinjaturtle/core.py @@ -6,6 +6,11 @@ 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 @@ -21,14 +26,6 @@ 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.""" @@ -40,8 +37,6 @@ 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: @@ -81,7 +76,15 @@ def parse_config(path: Path, fmt: str | None = None) -> tuple[str, Any]: if fmt == "yaml": text = path.read_text(encoding="utf-8") - data = yaml.safe_load(text) or {} + 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 {} return fmt, data if fmt == "json": diff --git a/tests/samples/bar.yaml b/tests/samples/bar.yaml deleted file mode 100644 index 1b63dbf..0000000 --- a/tests/samples/bar.yaml +++ /dev/null @@ -1,7 +0,0 @@ ---- -# Top comment -foo: "bar" - -blah: - - something - - else diff --git a/tests/samples/foo.json b/tests/samples/foo.json deleted file mode 100644 index 11093b5..0000000 --- a/tests/samples/foo.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "foo": "bar", - "nested": { - "a": 1, - "b": true - }, - "list": [ - 10, - 20 - ] -} diff --git a/tests/test_core.py b/tests/test_core.py index 7cfee90..7056518 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -200,10 +200,6 @@ 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") @@ -299,11 +295,18 @@ def test_generate_toml_template_from_text_edge_cases(): def test_yaml_roundtrip_with_list_and_comment(tmp_path: Path): - yaml_path = SAMPLES_DIR / "bar.yaml" - assert yaml_path.is_file(), f"Missing sample YAML file: {yaml_path}" + yaml_text = """ + # 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" 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" # 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) # Comment preserved @@ -334,10 +337,20 @@ def test_yaml_roundtrip_with_list_and_comment(tmp_path: Path): def test_json_roundtrip(tmp_path: Path): - json_path = SAMPLES_DIR / "foo.json" - assert json_path.is_file(), f"Missing sample JSON file: {json_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") - fmt, parsed = parse_config(json_path) + fmt, parsed = parse_config(cfg_path) assert fmt == "json" 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_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