Make templates more faithful to the original file in terms of indentation, newlines, no deserialisation of things like < or >. More test coverage
This commit is contained in:
parent
3d53d4fb30
commit
49fee7afe4
12 changed files with 579 additions and 52 deletions
|
|
@ -62,3 +62,73 @@ def test_cli_writes_output_files(tmp_path, capsys):
|
|||
# When writing to files, we shouldn't print the big headers
|
||||
assert "# defaults/main.yml" not in captured.out
|
||||
assert "# config.j2" not in captured.out
|
||||
|
||||
|
||||
def test_cli_folder_stdout(tmp_path, capsys):
|
||||
"""Folder mode without output paths prints defaults and all templates."""
|
||||
(tmp_path / "a.json").write_text('{"name": "one"}\n', encoding="utf-8")
|
||||
(tmp_path / "b.yaml").write_text("name: two\n", encoding="utf-8")
|
||||
|
||||
exit_code = cli._main([str(tmp_path), "-r", "role"])
|
||||
|
||||
assert exit_code == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "# defaults/main.yml" in captured.out
|
||||
assert "# config.json.j2" in captured.out
|
||||
assert "# config.yaml.j2" in captured.out
|
||||
assert "role_json_items" in captured.out
|
||||
assert "role_yaml_items" in captured.out
|
||||
|
||||
|
||||
def test_cli_folder_writes_template_directory(tmp_path, capsys):
|
||||
"""Folder mode writes multiple templates when template output is a directory."""
|
||||
src_dir = tmp_path / "src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "a.json").write_text('{"name": "one"}\n', encoding="utf-8")
|
||||
(src_dir / "b.yaml").write_text("name: two\n", encoding="utf-8")
|
||||
defaults_path = tmp_path / "defaults.yml"
|
||||
template_dir = tmp_path / "templates"
|
||||
|
||||
exit_code = cli._main(
|
||||
[
|
||||
str(src_dir),
|
||||
"-r",
|
||||
"role",
|
||||
"--defaults-output",
|
||||
str(defaults_path),
|
||||
"--template-output",
|
||||
str(template_dir),
|
||||
]
|
||||
)
|
||||
|
||||
assert exit_code == 0
|
||||
assert defaults_path.is_file()
|
||||
assert (template_dir / "config.json.j2").is_file()
|
||||
assert (template_dir / "config.yaml.j2").is_file()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
def test_cli_folder_single_output_file_when_one_format(tmp_path):
|
||||
src_dir = tmp_path / "src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "a.json").write_text('{"name": "one"}\n', encoding="utf-8")
|
||||
defaults_path = tmp_path / "defaults.yml"
|
||||
template_path = tmp_path / "config.j2"
|
||||
|
||||
exit_code = cli._main(
|
||||
[
|
||||
str(src_dir),
|
||||
"-r",
|
||||
"role",
|
||||
"--defaults-output",
|
||||
str(defaults_path),
|
||||
"--template-output",
|
||||
str(template_path),
|
||||
]
|
||||
)
|
||||
|
||||
assert exit_code == 0
|
||||
assert defaults_path.is_file()
|
||||
assert template_path.is_file()
|
||||
assert "to_json" in template_path.read_text(encoding="utf-8")
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from jinja2 import Environment
|
||||
|
||||
from jinjaturtle.core import (
|
||||
parse_config,
|
||||
|
|
@ -13,6 +15,7 @@ from jinjaturtle.core import (
|
|||
generate_jinja2_template,
|
||||
)
|
||||
from jinjaturtle.handlers.json import JsonHandler
|
||||
from jinjaturtle.loop_analyzer import LoopCandidate
|
||||
|
||||
SAMPLES_DIR = Path(__file__).parent / "samples"
|
||||
|
||||
|
|
@ -35,23 +38,136 @@ def test_json_roundtrip():
|
|||
assert defaults["foobar_nested_a"] == 1
|
||||
# Booleans are now preserved as booleans (not stringified)
|
||||
assert defaults["foobar_nested_b"] is True
|
||||
# List should be a list (not flattened to scalars)
|
||||
assert defaults["foobar_list"] == [10, 20]
|
||||
# JSON stays scalar/index-based so source formatting can be preserved.
|
||||
assert loop_candidates == []
|
||||
assert defaults["foobar_list_0"] == 10
|
||||
assert defaults["foobar_list_1"] == 20
|
||||
|
||||
# Template generation with loops
|
||||
template = generate_jinja2_template("json", parsed, "foobar", None, loop_candidates)
|
||||
|
||||
# Template should use | tojson for type preservation
|
||||
assert "{{ foobar_foo | tojson }}" in template
|
||||
assert "{{ foobar_nested_a | tojson }}" in template
|
||||
assert "{{ foobar_nested_b | tojson }}" in template
|
||||
# Template should use Ansible | to_json for type preservation without
|
||||
# HTML-safe escaping.
|
||||
assert "{{ foobar_foo | to_json(ensure_ascii=False) }}" in template
|
||||
assert "{{ foobar_nested_a | to_json(ensure_ascii=False) }}" in template
|
||||
assert "{{ foobar_nested_b | to_json(ensure_ascii=False) }}" in template
|
||||
assert "{{ foobar_list_0 | to_json(ensure_ascii=False) }}" in template
|
||||
assert "{{ foobar_list_1 | to_json(ensure_ascii=False) }}" in template
|
||||
assert "{% for" not in template
|
||||
|
||||
# List should use loop (not scalar indices)
|
||||
assert "{% for" in template
|
||||
assert "foobar_list" in template
|
||||
# Should NOT have scalar indices
|
||||
assert "foobar_list_0" not in template
|
||||
assert "foobar_list_1" not in template
|
||||
|
||||
def test_json_template_preserves_original_indentation_and_final_newline_state(
|
||||
tmp_path: Path,
|
||||
):
|
||||
json_text = (
|
||||
"{\n"
|
||||
' "default-runtime": "runsc",\n'
|
||||
' "runtimes": {\n'
|
||||
' "runsc": {\n'
|
||||
' "path": "/usr/bin/runsc"\n'
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}"
|
||||
)
|
||||
path = tmp_path / "daemon.json"
|
||||
path.write_text(json_text, encoding="utf-8")
|
||||
|
||||
fmt, parsed = parse_config(path)
|
||||
template = generate_jinja2_template(fmt, parsed, "docker", json_text, [])
|
||||
|
||||
assert ' "default-runtime"' in template
|
||||
assert ' "runsc"' in template
|
||||
assert ' "path"' in template
|
||||
assert not template.endswith("\n")
|
||||
|
||||
|
||||
def test_json_template_uses_non_html_safe_json_filter_for_angle_brackets(
|
||||
tmp_path: Path,
|
||||
):
|
||||
json_text = '{"version_requirement": ">= 8.0.0 < 9.0.0"}\n'
|
||||
path = tmp_path / "metadata.json"
|
||||
path.write_text(json_text, encoding="utf-8")
|
||||
|
||||
fmt, parsed = parse_config(path)
|
||||
template = generate_jinja2_template(fmt, parsed, "puppet", json_text, [])
|
||||
|
||||
assert "to_json(ensure_ascii=False)" in template
|
||||
assert "tojson" not in template
|
||||
|
||||
|
||||
def test_json_inline_object_array_roundtrips_without_expanding(tmp_path: Path):
|
||||
json_text = (
|
||||
"{\n"
|
||||
' "description": "Gets the last boot time of a Linux or Windows system",\n'
|
||||
' "implementations": [\n'
|
||||
' {"name": "last_boot_time_nix.sh", "requirements": ["shell"]},\n'
|
||||
' {"name": "last_boot_time_win.ps1", "requirements": ["powershell"]}\n'
|
||||
" ]\n"
|
||||
"}\n"
|
||||
)
|
||||
path = tmp_path / "metadata.json"
|
||||
path.write_text(json_text, encoding="utf-8")
|
||||
|
||||
fmt, parsed = parse_config(path)
|
||||
loop_candidates = analyze_loops(fmt, parsed)
|
||||
flat_items = flatten_config(fmt, parsed, loop_candidates)
|
||||
defaults = yaml.safe_load(
|
||||
generate_ansible_yaml("fact", flat_items, loop_candidates)
|
||||
)
|
||||
template = generate_jinja2_template(fmt, parsed, "fact", json_text, loop_candidates)
|
||||
|
||||
env = Environment(keep_trailing_newline=True)
|
||||
env.filters["to_json"] = lambda value, **kwargs: json.dumps(value, **kwargs)
|
||||
rendered = env.from_string(template).render(**defaults)
|
||||
|
||||
assert loop_candidates == []
|
||||
assert '{"name": {{ fact_implementations_0_name' in template
|
||||
assert rendered == json_text
|
||||
|
||||
|
||||
def test_json_direct_loop_generation_renders_valid_json_without_joined_objects():
|
||||
items = [
|
||||
{"name": "last_boot_time_nix.sh", "requirements": ["shell"]},
|
||||
{"name": "last_boot_time_win.ps1", "requirements": ["powershell"]},
|
||||
]
|
||||
parsed = {"implementations": items}
|
||||
candidate = LoopCandidate(
|
||||
path=("implementations",),
|
||||
loop_var="implementation",
|
||||
items=items,
|
||||
item_schema="simple_dict",
|
||||
)
|
||||
handler = JsonHandler()
|
||||
template = handler.generate_jinja2_template_with_loops(
|
||||
parsed, "fact", None, [candidate]
|
||||
)
|
||||
|
||||
env = Environment(keep_trailing_newline=True)
|
||||
env.filters["to_json"] = lambda value, **kwargs: json.dumps(value, **kwargs)
|
||||
rendered = env.from_string(template).render(fact_implementations=items)
|
||||
|
||||
assert "}, {" not in rendered
|
||||
assert json.loads(rendered) == parsed
|
||||
|
||||
|
||||
def test_json_scalar_span_collector_empty_containers_and_invalid_json():
|
||||
handler = JsonHandler()
|
||||
|
||||
assert handler._collect_json_scalar_spans('{"empty": [], "obj": {}}') == []
|
||||
assert handler._collect_json_scalar_spans('{"unterminated": [1,}') is None
|
||||
|
||||
|
||||
def test_json_template_falls_back_when_source_scanner_cannot_collect_spans(monkeypatch):
|
||||
handler = JsonHandler()
|
||||
monkeypatch.setattr(handler, "_collect_json_scalar_spans", lambda _text: None)
|
||||
|
||||
template = handler.generate_jinja2_template(
|
||||
{"answer": 42}, "role", original_text='{"answer": 42}'
|
||||
)
|
||||
|
||||
assert (
|
||||
template
|
||||
== '{\n "answer": {{ role_answer | to_json(ensure_ascii=False) }}\n}\n'
|
||||
)
|
||||
|
||||
|
||||
def test_generate_jinja2_template_json_type_error():
|
||||
|
|
|
|||
125
tests/test_multi.py
Normal file
125
tests/test_multi.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from jinjaturtle.multi import (
|
||||
_collect_dict_like_paths,
|
||||
_merge_union,
|
||||
defined_var_name,
|
||||
is_supported_file,
|
||||
iter_supported_files,
|
||||
process_directory,
|
||||
)
|
||||
|
||||
|
||||
def test_iter_supported_files_handles_files_dirs_and_missing_paths(tmp_path: Path):
|
||||
json_file = tmp_path / "config.json"
|
||||
text_file = tmp_path / "notes.txt"
|
||||
nested = tmp_path / "nested"
|
||||
nested.mkdir()
|
||||
nested_yaml = nested / "config.yaml"
|
||||
|
||||
json_file.write_text('{"name": "one"}\n', encoding="utf-8")
|
||||
text_file.write_text("ignore me\n", encoding="utf-8")
|
||||
nested_yaml.write_text("name: two\n", encoding="utf-8")
|
||||
|
||||
assert is_supported_file(json_file)
|
||||
assert not is_supported_file(text_file)
|
||||
assert iter_supported_files(json_file, recursive=False) == [json_file]
|
||||
assert iter_supported_files(text_file, recursive=False) == []
|
||||
assert iter_supported_files(tmp_path, recursive=False) == [json_file]
|
||||
assert iter_supported_files(tmp_path, recursive=True) == [json_file, nested_yaml]
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
iter_supported_files(tmp_path / "missing", recursive=False)
|
||||
|
||||
|
||||
def test_merge_union_and_collect_dict_like_paths():
|
||||
merged = _merge_union(
|
||||
{"name": "one", "ports": [80], "nested": {"a": 1}},
|
||||
{"ports": [80, 443], "nested": {"b": 2}, "enabled": True},
|
||||
)
|
||||
|
||||
assert merged == {
|
||||
"name": "one",
|
||||
"ports": [80, 443],
|
||||
"nested": {"a": 1, "b": 2},
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
containers, leaves = _collect_dict_like_paths(merged)
|
||||
assert ("nested",) in containers
|
||||
assert ("nested", "a") in containers
|
||||
assert ("ports", "1") in leaves
|
||||
|
||||
|
||||
def test_process_directory_multiple_formats(tmp_path: Path):
|
||||
(tmp_path / "a.json").write_text('{"name": "one"}\n', encoding="utf-8")
|
||||
(tmp_path / "b.yaml").write_text("name: one\nextra: true\n", encoding="utf-8")
|
||||
(tmp_path / "c.toml").write_text('name = "one"\n', encoding="utf-8")
|
||||
(tmp_path / "d.ini").write_text("[main]\nname = one\n", encoding="utf-8")
|
||||
(tmp_path / "e.xml").write_text("<root><name>one</name></root>", encoding="utf-8")
|
||||
|
||||
defaults_yaml, outputs = process_directory(
|
||||
tmp_path, recursive=False, role_prefix="role"
|
||||
)
|
||||
defaults = yaml.safe_load(defaults_yaml)
|
||||
by_fmt = {output.fmt: output for output in outputs}
|
||||
|
||||
assert set(by_fmt) == {"ini", "json", "toml", "xml", "yaml"}
|
||||
assert set(defaults) == {
|
||||
"role_ini_items",
|
||||
"role_json_items",
|
||||
"role_toml_items",
|
||||
"role_xml_items",
|
||||
"role_yaml_items",
|
||||
}
|
||||
assert defaults["role_json_items"][0]["data"] == {"name": "one"}
|
||||
assert (
|
||||
by_fmt["json"].template
|
||||
== "{{ data | to_json(indent=2, ensure_ascii=False) }}\n"
|
||||
)
|
||||
assert "{{ role_main_name }}" in by_fmt["ini"].template
|
||||
assert "{{ role_name }}" in by_fmt["xml"].template
|
||||
|
||||
|
||||
def test_process_directory_yaml_union_marks_optional_keys(tmp_path: Path):
|
||||
(tmp_path / "one.yaml").write_text("name: one\nextra: yes\n", encoding="utf-8")
|
||||
(tmp_path / "two.yaml").write_text("name: two\n", encoding="utf-8")
|
||||
|
||||
defaults_yaml, outputs = process_directory(
|
||||
tmp_path, recursive=False, role_prefix="role"
|
||||
)
|
||||
defaults = yaml.safe_load(defaults_yaml)
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert outputs[0].fmt == "yaml"
|
||||
assert "{% if role_defined_extra is defined %}" in outputs[0].template
|
||||
assert defaults["role_items"][0][defined_var_name("role", ("extra",))] is True
|
||||
assert defined_var_name("role", ("extra",)) not in defaults["role_items"][1]
|
||||
|
||||
|
||||
def test_process_directory_ini_union_marks_optional_sections_and_keys(tmp_path: Path):
|
||||
(tmp_path / "one.ini").write_text(
|
||||
"[main]\nname = one\n[extra]\nflag = yes\n", encoding="utf-8"
|
||||
)
|
||||
(tmp_path / "two.ini").write_text("[main]\nname = two\n", encoding="utf-8")
|
||||
|
||||
defaults_yaml, outputs = process_directory(
|
||||
tmp_path, recursive=False, role_prefix="role"
|
||||
)
|
||||
defaults = yaml.safe_load(defaults_yaml)
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert outputs[0].fmt == "ini"
|
||||
assert "{% if role_defined_extra is defined %}" in outputs[0].template
|
||||
assert defaults["role_items"][0][defined_var_name("role", ("extra",))] is True
|
||||
assert defined_var_name("role", ("extra",)) not in defaults["role_items"][1]
|
||||
|
||||
|
||||
def test_process_directory_rejects_empty_folder(tmp_path: Path):
|
||||
with pytest.raises(ValueError, match="No supported config files"):
|
||||
process_directory(tmp_path, recursive=False, role_prefix="role")
|
||||
|
|
@ -31,6 +31,7 @@ from jinjaturtle.core import (
|
|||
def render_template(template: str, variables: dict[str, Any]) -> str:
|
||||
"""Render a Jinja2 template with variables."""
|
||||
env = Environment(undefined=StrictUndefined)
|
||||
env.filters["to_json"] = lambda value, **kwargs: json.dumps(value, **kwargs)
|
||||
jinja_template = env.from_string(template)
|
||||
return jinja_template.render(variables)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import yaml
|
|||
|
||||
from jinjaturtle.core import (
|
||||
parse_config,
|
||||
analyze_loops,
|
||||
flatten_config,
|
||||
generate_ansible_yaml,
|
||||
generate_jinja2_template,
|
||||
|
|
@ -204,6 +205,36 @@ def test_yaml_loop_preserves_following_top_level_comments(tmp_path: Path):
|
|||
assert yaml.safe_load(rendered) == yaml.safe_load(text)
|
||||
|
||||
|
||||
def test_yaml_scalar_loop_preserves_quoted_list_items(tmp_path: Path):
|
||||
from jinja2 import Template
|
||||
|
||||
text = textwrap.dedent(
|
||||
"""
|
||||
files:
|
||||
- 'spec/unit/puppet/provider/dsc_base_provider/dsc_base_provider_spec.rb'
|
||||
- 'spec/unit/pwsh/util_spec.rb'
|
||||
- 'spec/unit/pwsh/windows_powershell_spec.rb'
|
||||
"""
|
||||
).lstrip()
|
||||
path = tmp_path / "quoted-list.yaml"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
fmt, parsed = parse_config(path)
|
||||
loop_candidates = analyze_loops(fmt, parsed)
|
||||
flat_items = flatten_config(fmt, parsed, loop_candidates)
|
||||
defaults = yaml.safe_load(
|
||||
generate_ansible_yaml("role", flat_items, loop_candidates)
|
||||
)
|
||||
template = generate_jinja2_template(
|
||||
fmt, parsed, "role", original_text=text, loop_candidates=loop_candidates
|
||||
)
|
||||
rendered = Template(template).render(**defaults)
|
||||
|
||||
assert loop_candidates
|
||||
assert "- '{{" in template
|
||||
assert rendered == text
|
||||
|
||||
|
||||
def test_yaml_bool_scalars_render_with_yaml_spelling(tmp_path: Path):
|
||||
from jinja2 import Template
|
||||
|
||||
|
|
|
|||
|
|
@ -454,6 +454,7 @@ class TestStructuralConsistency:
|
|||
|
||||
# Try to render the template
|
||||
env = Environment(undefined=StrictUndefined)
|
||||
env.filters["to_json"] = lambda value, **kwargs: json.dumps(value, **kwargs)
|
||||
try:
|
||||
jinja_template = env.from_string(template)
|
||||
rendered = jinja_template.render(variables)
|
||||
|
|
@ -520,11 +521,9 @@ class TestRegressionBugs:
|
|||
"app_database" in template
|
||||
), f"Template should reference app_database\n{template}"
|
||||
|
||||
def test_json_array_no_index_refs(self):
|
||||
def test_json_array_uses_index_refs_for_source_fidelity(self):
|
||||
"""
|
||||
Regression test: JSON arrays should not generate index references.
|
||||
|
||||
Bug: Template had {{ app_list_0 }}, {{ app_list_1 }} when YAML had app_list as list.
|
||||
JSON arrays stay index-based so original inline formatting can survive.
|
||||
"""
|
||||
import json
|
||||
|
||||
|
|
@ -536,22 +535,18 @@ class TestRegressionBugs:
|
|||
|
||||
ansible_yaml = generate_ansible_yaml("app", flat_items, loop_candidates)
|
||||
template = generate_jinja2_template(
|
||||
"json", parsed, "app", None, loop_candidates
|
||||
"json", parsed, "app", json_text, loop_candidates
|
||||
)
|
||||
|
||||
# YAML should have app_items as a list
|
||||
defaults = yaml.safe_load(ansible_yaml)
|
||||
assert isinstance(defaults.get("app_items"), list)
|
||||
|
||||
# Template should NOT have app_items_0, app_items_1, app_items_2
|
||||
for i in range(3):
|
||||
assert (
|
||||
f"app_items_{i}" not in template
|
||||
), f"Template incorrectly uses scalar 'app_items_{i}'\n{template}"
|
||||
|
||||
# Template SHOULD use a loop
|
||||
assert "{% for" in template
|
||||
assert "app_items" in template
|
||||
assert loop_candidates == []
|
||||
assert defaults["app_items_0"] == 1
|
||||
assert defaults["app_items_1"] == 2
|
||||
assert defaults["app_items_2"] == 3
|
||||
assert "{% for" not in template
|
||||
assert "app_items_0" in template
|
||||
assert "app_items_1" in template
|
||||
assert "app_items_2" in template
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue