Refactor handlers to be in their own classes for easier maintainability
This commit is contained in:
parent
d1ca60b779
commit
85f21e739d
19 changed files with 1826 additions and 1463 deletions
34
tests/test_base_handler.py
Normal file
34
tests/test_base_handler.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from jinjaturtle.handlers.base import BaseHandler
|
||||
|
||||
|
||||
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"
|
||||
handler = BaseHandler()
|
||||
value, comment = handler._split_inline_comment(text, {"#"})
|
||||
assert "not comment" in value
|
||||
assert comment.strip() == "# real"
|
||||
|
||||
|
||||
def test_base_handler_abstract_methods_raise_not_implemented(tmp_path: Path):
|
||||
"""
|
||||
Ensure the abstract methods on BaseHandler all raise NotImplementedError.
|
||||
This covers the stub implementations.
|
||||
"""
|
||||
handler = BaseHandler()
|
||||
dummy_path = tmp_path / "dummy.cfg"
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
handler.parse(dummy_path)
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
handler.flatten(object())
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
handler.generate_template(parsed=object(), role_prefix="role")
|
||||
|
|
@ -1,653 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import configparser
|
||||
import pytest
|
||||
import textwrap
|
||||
import yaml
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import jinjaturtle.core as core
|
||||
from jinjaturtle.core import (
|
||||
detect_format,
|
||||
parse_config,
|
||||
flatten_config,
|
||||
generate_defaults_yaml,
|
||||
generate_template,
|
||||
make_var_name,
|
||||
)
|
||||
|
||||
SAMPLES_DIR = Path(__file__).parent / "samples"
|
||||
|
||||
|
||||
def test_make_var_name_basic():
|
||||
# simple sanity checks on the naming rules
|
||||
assert (
|
||||
make_var_name("jinjaturtle", ("somesection", "foo"))
|
||||
== "jinjaturtle_somesection_foo"
|
||||
)
|
||||
assert (
|
||||
make_var_name("JinjaTurtle", ("Other-Section", "some value"))
|
||||
== "jinjaturtle_other_section_some_value"
|
||||
)
|
||||
# no trailing underscores, all lowercase, no spaces
|
||||
name = make_var_name("MyRole", (" Section Name ", "Key-Name "))
|
||||
assert name == name.lower()
|
||||
assert " " not in name
|
||||
assert not name.endswith("_")
|
||||
|
||||
|
||||
def test_make_var_name_empty_path_returns_prefix():
|
||||
# Cover the branch where there are no path components.
|
||||
assert make_var_name("MyRole", ()) == "myrole"
|
||||
|
||||
|
||||
def test_detect_format_explicit_overrides_suffix(tmp_path: Path):
|
||||
# Explicit format should win over file suffix.
|
||||
cfg_path = tmp_path / "config.ini"
|
||||
cfg_path.write_text("[section]\nkey=value\n", encoding="utf-8")
|
||||
|
||||
fmt = detect_format(cfg_path, explicit="toml")
|
||||
assert fmt == "toml"
|
||||
|
||||
|
||||
def test_detect_format_fallback_ini(tmp_path: Path):
|
||||
# Unknown suffix should fall back to "ini".
|
||||
cfg_path = tmp_path / "weird.cnf"
|
||||
cfg_path.write_text("[section]\nkey=value\n", encoding="utf-8")
|
||||
|
||||
fmt, parsed = parse_config(cfg_path) # no explicit fmt
|
||||
assert fmt == "ini"
|
||||
# parsed should be an INI ConfigParser with our section/key
|
||||
flat = flatten_config(fmt, parsed)
|
||||
assert any(path == ("section", "key") for path, _ in flat)
|
||||
|
||||
|
||||
def test_toml_sample_roundtrip():
|
||||
toml_path = SAMPLES_DIR / "tom.toml"
|
||||
assert toml_path.is_file(), f"Missing sample TOML file: {toml_path}"
|
||||
|
||||
fmt, parsed = parse_config(toml_path)
|
||||
assert fmt == "toml"
|
||||
|
||||
flat_items = flatten_config(fmt, parsed)
|
||||
assert flat_items
|
||||
|
||||
defaults_yaml = generate_defaults_yaml("jinjaturtle", flat_items)
|
||||
defaults = yaml.safe_load(defaults_yaml)
|
||||
|
||||
# defaults should be a non-empty dict
|
||||
assert isinstance(defaults, dict)
|
||||
assert defaults, "Expected non-empty defaults for TOML sample"
|
||||
|
||||
# all keys should be lowercase, start with prefix, and have no spaces
|
||||
for key in defaults:
|
||||
assert key.startswith("jinjaturtle_")
|
||||
assert key == key.lower()
|
||||
assert " " not in key
|
||||
|
||||
# template generation – **now with original_text**
|
||||
original_text = toml_path.read_text(encoding="utf-8")
|
||||
template = generate_template(
|
||||
fmt, parsed, "jinjaturtle", original_text=original_text
|
||||
)
|
||||
assert isinstance(template, str)
|
||||
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
|
||||
for var_name in defaults:
|
||||
assert (
|
||||
var_name in template
|
||||
), f"Variable {var_name} not referenced in TOML template"
|
||||
|
||||
|
||||
def test_ini_php_sample_roundtrip():
|
||||
ini_path = SAMPLES_DIR / "php.ini"
|
||||
assert ini_path.is_file(), f"Missing sample INI file: {ini_path}"
|
||||
|
||||
fmt, parsed = parse_config(ini_path)
|
||||
assert fmt == "ini"
|
||||
|
||||
flat_items = flatten_config(fmt, parsed)
|
||||
assert flat_items, "Expected at least one flattened item from php.ini sample"
|
||||
|
||||
defaults_yaml = generate_defaults_yaml("php", flat_items)
|
||||
defaults = yaml.safe_load(defaults_yaml)
|
||||
|
||||
# defaults should be a non-empty dict
|
||||
assert isinstance(defaults, dict)
|
||||
assert defaults, "Expected non-empty defaults for php.ini sample"
|
||||
|
||||
# all keys should be lowercase, start with prefix, and have no spaces
|
||||
for key in defaults:
|
||||
assert key.startswith("php_")
|
||||
assert key == key.lower()
|
||||
assert " " not in key
|
||||
|
||||
# template generation
|
||||
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 template.strip(), "Template for php.ini sample should not be empty"
|
||||
|
||||
# each default variable name should appear in the template as a Jinja placeholder
|
||||
for var_name in defaults:
|
||||
assert (
|
||||
var_name in template
|
||||
), f"Variable {var_name} not referenced in INI template"
|
||||
|
||||
|
||||
def test_formats_match_expected_extensions():
|
||||
"""
|
||||
Sanity check that format detection lines up with the filenames
|
||||
we’re using for the samples.
|
||||
"""
|
||||
toml_path = SAMPLES_DIR / "tom.toml"
|
||||
ini_path = SAMPLES_DIR / "php.ini"
|
||||
xml_path = SAMPLES_DIR / "ossec.xml"
|
||||
|
||||
fmt_toml, _ = parse_config(toml_path)
|
||||
fmt_ini, _ = parse_config(ini_path)
|
||||
fmt_xml, _ = parse_config(xml_path)
|
||||
|
||||
assert fmt_toml == "toml"
|
||||
assert fmt_ini == "ini"
|
||||
assert fmt_xml == "xml"
|
||||
|
||||
|
||||
def test_parse_config_toml_missing_tomllib(monkeypatch):
|
||||
"""
|
||||
Force tomllib to None to hit the RuntimeError branch when parsing TOML.
|
||||
"""
|
||||
toml_path = SAMPLES_DIR / "tom.toml"
|
||||
|
||||
# Simulate an environment without tomllib/tomli
|
||||
monkeypatch.setattr(core, "tomllib", None)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc:
|
||||
core.parse_config(toml_path, fmt="toml")
|
||||
assert "tomllib/tomli is required" in str(exc.value)
|
||||
|
||||
|
||||
def test_parse_config_unsupported_format(tmp_path: Path):
|
||||
"""
|
||||
Hit the ValueError in parse_config when fmt is not a supported format.
|
||||
"""
|
||||
cfg_path = tmp_path / "config.whatever"
|
||||
cfg_path.write_text("", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
parse_config(cfg_path, fmt="bogus")
|
||||
|
||||
|
||||
def test_generate_template_type_and_format_errors():
|
||||
"""
|
||||
Exercise the error branches in generate_template:
|
||||
- toml with non-dict parsed
|
||||
- ini with non-ConfigParser parsed
|
||||
- yaml with wrong parsed type
|
||||
- completely unsupported fmt (with and without original_text)
|
||||
"""
|
||||
# wrong type for TOML
|
||||
with pytest.raises(TypeError):
|
||||
generate_template("toml", parsed="not a dict", role_prefix="role")
|
||||
|
||||
# wrong type for INI
|
||||
with pytest.raises(TypeError):
|
||||
generate_template("ini", parsed={"not": "a configparser"}, role_prefix="role")
|
||||
|
||||
# wrong type for YAML
|
||||
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")
|
||||
|
||||
# unsupported format, with original_text
|
||||
with pytest.raises(ValueError):
|
||||
generate_template(
|
||||
"bogusfmt",
|
||||
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
|
||||
|
||||
|
||||
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}"
|
||||
|
||||
fmt, parsed = parse_config(yaml_path)
|
||||
|
||||
assert fmt == "yaml"
|
||||
|
||||
flat_items = flatten_config(fmt, parsed)
|
||||
defaults_yaml = generate_defaults_yaml("foobar", flat_items)
|
||||
defaults = yaml.safe_load(defaults_yaml)
|
||||
|
||||
# Defaults: keys are flattened with indices
|
||||
assert defaults["foobar_foo"] == "bar"
|
||||
assert defaults["foobar_blah_0"] == "something"
|
||||
assert defaults["foobar_blah_1"] == "else"
|
||||
|
||||
# Template generation (preserving comments)
|
||||
original_text = yaml_path.read_text(encoding="utf-8")
|
||||
template = generate_template(fmt, parsed, "foobar", original_text=original_text)
|
||||
|
||||
# Comment preserved
|
||||
assert "# Top comment" in template
|
||||
|
||||
# Scalar replacement
|
||||
assert "foo:" in template
|
||||
assert "foobar_foo" in template
|
||||
|
||||
# List items use indexed vars, not "item"
|
||||
assert "foobar_blah_0" in template
|
||||
assert "foobar_blah_1" in template
|
||||
assert "{{ foobar_blah }}" not in template
|
||||
assert "foobar_blah_item" not in template
|
||||
|
||||
|
||||
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}"
|
||||
|
||||
fmt, parsed = parse_config(json_path)
|
||||
assert fmt == "json"
|
||||
|
||||
flat_items = flatten_config(fmt, parsed)
|
||||
defaults_yaml = generate_defaults_yaml("foobar", flat_items)
|
||||
defaults = yaml.safe_load(defaults_yaml)
|
||||
|
||||
# Defaults: nested keys and list indices
|
||||
assert defaults["foobar_foo"] == "bar"
|
||||
assert defaults["foobar_nested_a"] == 1
|
||||
# Bool normalized to string "true"
|
||||
assert defaults["foobar_nested_b"] == "true"
|
||||
assert defaults["foobar_list_0"] == 10
|
||||
assert defaults["foobar_list_1"] == 20
|
||||
|
||||
# Template generation (JSON has no comments, so we just rebuild)
|
||||
template = generate_template(fmt, parsed, "foobar")
|
||||
|
||||
assert '"foo": "{{ foobar_foo }}"' in template
|
||||
assert "foobar_nested_a" in template
|
||||
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
|
||||
|
||||
|
||||
def test_xml_roundtrip_ossec_web_rules():
|
||||
xml_path = SAMPLES_DIR / "ossec.xml"
|
||||
assert xml_path.is_file(), f"Missing sample XML file: {xml_path}"
|
||||
|
||||
fmt, parsed = parse_config(xml_path)
|
||||
assert fmt == "xml"
|
||||
|
||||
flat_items = flatten_config(fmt, parsed)
|
||||
assert flat_items, "Expected at least one flattened item from XML sample"
|
||||
|
||||
defaults_yaml = generate_defaults_yaml("ossec", flat_items)
|
||||
defaults = yaml.safe_load(defaults_yaml)
|
||||
|
||||
# defaults should be a non-empty dict
|
||||
assert isinstance(defaults, dict)
|
||||
assert defaults, "Expected non-empty defaults for XML sample"
|
||||
|
||||
# all keys should be lowercase, start with prefix, and have no spaces
|
||||
for key in defaults:
|
||||
assert key.startswith("ossec_")
|
||||
assert key == key.lower()
|
||||
assert " " not in key
|
||||
|
||||
# Root <group name="web,accesslog,"> attribute should flatten to ossec_name
|
||||
assert defaults["ossec_name"] == "web,accesslog,"
|
||||
|
||||
# There should be at least one default for rule id="31100"
|
||||
id_keys = [k for k, v in defaults.items() if v == "31100"]
|
||||
assert id_keys, "Expected to find a default for rule id 31100"
|
||||
|
||||
# At least one of them should be the rule *id* attribute
|
||||
assert any(
|
||||
key.startswith("ossec_rule_") and key.endswith("_id") for key in id_keys
|
||||
), f"Expected at least one *_id var for value 31100, got: {id_keys}"
|
||||
|
||||
# Template generation (preserving comments)
|
||||
original_text = xml_path.read_text(encoding="utf-8")
|
||||
template = generate_template(fmt, parsed, "ossec", original_text=original_text)
|
||||
assert isinstance(template, str)
|
||||
assert template.strip(), "Template for XML sample should not be empty"
|
||||
|
||||
# Top-of-file and mid-file comments should be preserved
|
||||
assert "Official Web access rules for OSSEC." in template
|
||||
assert "Rules to ignore crawlers" in template
|
||||
|
||||
# Each default variable name should appear in the template as a Jinja placeholder
|
||||
for var_name in defaults:
|
||||
assert (
|
||||
var_name in template
|
||||
), f"Variable {var_name} not referenced in XML template"
|
||||
|
||||
|
||||
def test_generate_xml_template_from_text_edge_cases():
|
||||
"""
|
||||
Exercise XML text edge cases:
|
||||
- XML declaration and DOCTYPE in prolog
|
||||
- top-level and inner comments
|
||||
- repeated child elements (indexing)
|
||||
- attributes and text content
|
||||
"""
|
||||
text = textwrap.dedent(
|
||||
"""\
|
||||
<?xml version="1.0"?>
|
||||
<!-- top comment -->
|
||||
<!DOCTYPE something>
|
||||
<root attr="1">
|
||||
<!-- inner comment -->
|
||||
<child attr="2">text</child>
|
||||
<child>other</child>
|
||||
</root>
|
||||
"""
|
||||
)
|
||||
|
||||
tmpl = core._generate_xml_template_from_text("role", text)
|
||||
|
||||
# Prolog and comments preserved
|
||||
assert "<?xml version" in tmpl
|
||||
assert "top comment" in tmpl
|
||||
assert "inner comment" in tmpl
|
||||
|
||||
# Root attribute becomes a variable (path ("@attr",) -> role_attr)
|
||||
assert "role_attr" in tmpl
|
||||
|
||||
# Repeated <child> elements should be indexed in both attr and text
|
||||
assert "role_child_0_attr" in tmpl
|
||||
assert "role_child_0" in tmpl
|
||||
assert "role_child_1" in tmpl
|
||||
|
||||
|
||||
def test_generate_template_xml_type_error():
|
||||
"""
|
||||
Wrong type for XML in generate_template should raise TypeError.
|
||||
"""
|
||||
with pytest.raises(TypeError):
|
||||
generate_template("xml", parsed="not an element", role_prefix="role")
|
||||
|
||||
|
||||
def test_flatten_config_xml_type_error():
|
||||
"""
|
||||
Wrong type for XML in flatten_config should raise TypeError.
|
||||
"""
|
||||
with pytest.raises(TypeError):
|
||||
flatten_config("xml", parsed="not-an-element")
|
||||
|
||||
|
||||
def test_generate_template_xml_structural_fallback():
|
||||
"""
|
||||
When original_text is not provided for XML, generate_template should use
|
||||
the structural fallback path (ET.tostring + _generate_xml_template_from_text).
|
||||
"""
|
||||
xml_text = textwrap.dedent(
|
||||
"""\
|
||||
<root attr="1">
|
||||
<child>2</child>
|
||||
<node attr="x">text</node>
|
||||
</root>
|
||||
"""
|
||||
)
|
||||
root = ET.fromstring(xml_text)
|
||||
|
||||
tmpl = generate_template("xml", parsed=root, role_prefix="role")
|
||||
|
||||
# Root attribute path ("@attr",) -> role_attr
|
||||
assert "role_attr" in tmpl
|
||||
|
||||
# Simple child element text ("child",) -> role_child
|
||||
assert "role_child" in tmpl
|
||||
|
||||
# Element with both attr and text:
|
||||
# - attr -> ("node", "@attr") -> role_node_attr
|
||||
# - text -> ("node", "value") -> role_node_value
|
||||
assert "role_node_attr" in tmpl
|
||||
assert "role_node_value" in tmpl
|
||||
|
||||
|
||||
def test_split_xml_prolog_only_whitespace():
|
||||
"""
|
||||
Whitespace-only input: prolog is the whitespace, body is empty.
|
||||
Exercises the 'if i >= n: break' path.
|
||||
"""
|
||||
text = " \n\t"
|
||||
prolog, body = core._split_xml_prolog(text)
|
||||
assert prolog == text
|
||||
assert body == ""
|
||||
|
||||
|
||||
def test_split_xml_prolog_unterminated_declaration():
|
||||
"""
|
||||
Unterminated XML declaration should hit the 'end == -1' branch and
|
||||
treat the whole string as body.
|
||||
"""
|
||||
text = "<?xml version='1.0'"
|
||||
prolog, body = core._split_xml_prolog(text)
|
||||
assert prolog == ""
|
||||
assert body == text
|
||||
|
||||
|
||||
def test_split_xml_prolog_unterminated_comment():
|
||||
"""
|
||||
Unterminated comment should likewise hit its 'end == -1' branch.
|
||||
"""
|
||||
text = "<!-- no end"
|
||||
prolog, body = core._split_xml_prolog(text)
|
||||
assert prolog == ""
|
||||
assert body == text
|
||||
|
||||
|
||||
def test_split_xml_prolog_unterminated_doctype():
|
||||
"""
|
||||
Unterminated DOCTYPE should hit the DOCTYPE 'end == -1' branch.
|
||||
"""
|
||||
text = "<!DOCTYPE foo"
|
||||
prolog, body = core._split_xml_prolog(text)
|
||||
assert prolog == ""
|
||||
assert body == text
|
||||
|
||||
|
||||
def test_split_xml_prolog_unexpected_content():
|
||||
"""
|
||||
Non-XML content at the start should trigger the 'unexpected content'
|
||||
break and be returned entirely as body.
|
||||
"""
|
||||
text = "garbage<root/>"
|
||||
prolog, body = core._split_xml_prolog(text)
|
||||
assert prolog == ""
|
||||
assert body == text
|
||||
|
||||
|
||||
def test_flatten_xml_text_with_attributes_uses_value_suffix():
|
||||
"""
|
||||
When an element has both attributes and text, _flatten_xml should store
|
||||
the text at path + ('value',), not just path.
|
||||
"""
|
||||
xml_text = "<root><node attr='x'>text</node></root>"
|
||||
root = ET.fromstring(xml_text)
|
||||
|
||||
items = flatten_config("xml", root)
|
||||
|
||||
# Attribute path: ("node", "@attr") -> "x"
|
||||
assert (("node", "@attr"), "x") in items
|
||||
|
||||
# Text-with-attrs path: ("node", "value") -> "text"
|
||||
assert (("node", "value"), "text") in items
|
||||
202
tests/test_core_utils.py
Normal file
202
tests/test_core_utils.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
import jinjaturtle.core as core
|
||||
from jinjaturtle.core import (
|
||||
detect_format,
|
||||
parse_config,
|
||||
flatten_config,
|
||||
generate_defaults_yaml,
|
||||
generate_template,
|
||||
make_var_name,
|
||||
)
|
||||
|
||||
SAMPLES_DIR = Path(__file__).parent / "samples"
|
||||
|
||||
|
||||
def test_make_var_name_basic():
|
||||
# simple sanity checks on the naming rules
|
||||
assert (
|
||||
make_var_name("jinjaturtle", ("somesection", "foo"))
|
||||
== "jinjaturtle_somesection_foo"
|
||||
)
|
||||
assert (
|
||||
make_var_name("JinjaTurtle", ("Other-Section", "some value"))
|
||||
== "jinjaturtle_other_section_some_value"
|
||||
)
|
||||
# no trailing underscores, all lowercase, no spaces
|
||||
name = make_var_name("MyRole", (" Section Name ", "Key-Name "))
|
||||
assert name == name.lower()
|
||||
assert " " not in name
|
||||
assert not name.endswith("_")
|
||||
|
||||
|
||||
def test_make_var_name_empty_path_returns_prefix():
|
||||
# Cover the branch where there are no path components.
|
||||
assert make_var_name("MyRole", ()) == "myrole"
|
||||
|
||||
|
||||
def test_detect_format_explicit_overrides_suffix(tmp_path: Path):
|
||||
# Explicit format should win over file suffix.
|
||||
cfg_path = tmp_path / "config.ini"
|
||||
cfg_path.write_text("[section]\nkey=value\n", encoding="utf-8")
|
||||
|
||||
fmt = detect_format(cfg_path, explicit="toml")
|
||||
assert fmt == "toml"
|
||||
|
||||
|
||||
def test_detect_format_fallback_ini(tmp_path: Path):
|
||||
# Unknown suffix should fall back to "ini".
|
||||
cfg_path = tmp_path / "weird.cnf"
|
||||
cfg_path.write_text("[section]\nkey=value\n", encoding="utf-8")
|
||||
|
||||
fmt, parsed = parse_config(cfg_path) # no explicit fmt
|
||||
assert fmt == "ini"
|
||||
# parsed should be an INI ConfigParser with our section/key
|
||||
flat = flatten_config(fmt, parsed)
|
||||
assert any(path == ("section", "key") for path, _ in flat)
|
||||
|
||||
|
||||
def test_formats_match_expected_extensions():
|
||||
"""
|
||||
Sanity check that format detection lines up with the filenames
|
||||
we’re using for the samples.
|
||||
"""
|
||||
toml_path = SAMPLES_DIR / "tom.toml"
|
||||
ini_path = SAMPLES_DIR / "php.ini"
|
||||
xml_path = SAMPLES_DIR / "ossec.xml"
|
||||
|
||||
fmt_toml, _ = parse_config(toml_path)
|
||||
fmt_ini, _ = parse_config(ini_path)
|
||||
fmt_xml, _ = parse_config(xml_path)
|
||||
|
||||
assert fmt_toml == "toml"
|
||||
assert fmt_ini == "ini"
|
||||
assert fmt_xml == "xml"
|
||||
|
||||
|
||||
def test_parse_config_unsupported_format(tmp_path: Path):
|
||||
"""
|
||||
Hit the ValueError in parse_config when fmt is not a supported format.
|
||||
"""
|
||||
cfg_path = tmp_path / "config.whatever"
|
||||
cfg_path.write_text("", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
parse_config(cfg_path, fmt="bogus")
|
||||
|
||||
|
||||
def test_generate_template_type_and_format_errors():
|
||||
"""
|
||||
Exercise the error branches in generate_template:
|
||||
- toml with non-dict parsed
|
||||
- ini with non-ConfigParser parsed
|
||||
- yaml with wrong parsed type
|
||||
- json with wrong parsed type
|
||||
- completely unsupported fmt (with and without original_text)
|
||||
"""
|
||||
# wrong type for TOML
|
||||
with pytest.raises(TypeError):
|
||||
generate_template("toml", parsed="not a dict", role_prefix="role")
|
||||
|
||||
# wrong type for INI
|
||||
with pytest.raises(TypeError):
|
||||
generate_template("ini", parsed={"not": "a configparser"}, role_prefix="role")
|
||||
|
||||
# wrong type for YAML
|
||||
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")
|
||||
|
||||
# unsupported format, with original_text
|
||||
with pytest.raises(ValueError):
|
||||
generate_template(
|
||||
"bogusfmt",
|
||||
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_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()}
|
||||
|
||||
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
|
||||
|
||||
|
||||
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_normalize_default_value_bool_inputs_are_stringified():
|
||||
"""
|
||||
Real boolean values should be turned into quoted 'true'/'false' strings
|
||||
by _normalize_default_value via generate_defaults_yaml.
|
||||
"""
|
||||
flat_items = [
|
||||
(("section", "flag_true"), True),
|
||||
(("section", "flag_false"), False),
|
||||
]
|
||||
defaults_yaml = generate_defaults_yaml("role", flat_items)
|
||||
data = yaml.safe_load(defaults_yaml)
|
||||
|
||||
assert data["role_section_flag_true"] == "true"
|
||||
assert data["role_section_flag_false"] == "false"
|
||||
|
||||
|
||||
def test_flatten_config_unsupported_format():
|
||||
"""
|
||||
Calling flatten_config with an unknown fmt should raise ValueError.
|
||||
"""
|
||||
with pytest.raises(ValueError) as exc:
|
||||
flatten_config("bogusfmt", parsed=None)
|
||||
|
||||
assert "Unsupported format" in str(exc.value)
|
||||
93
tests/test_ini_handler.py
Normal file
93
tests/test_ini_handler.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import configparser
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from jinjaturtle.core import (
|
||||
parse_config,
|
||||
flatten_config,
|
||||
generate_defaults_yaml,
|
||||
generate_template,
|
||||
)
|
||||
from jinjaturtle.handlers.ini import IniHandler
|
||||
|
||||
SAMPLES_DIR = Path(__file__).parent / "samples"
|
||||
|
||||
|
||||
def test_ini_php_sample_roundtrip():
|
||||
ini_path = SAMPLES_DIR / "php.ini"
|
||||
assert ini_path.is_file(), f"Missing sample INI file: {ini_path}"
|
||||
|
||||
fmt, parsed = parse_config(ini_path)
|
||||
assert fmt == "ini"
|
||||
|
||||
flat_items = flatten_config(fmt, parsed)
|
||||
assert flat_items, "Expected at least one flattened item from php.ini sample"
|
||||
|
||||
defaults_yaml = generate_defaults_yaml("php", flat_items)
|
||||
defaults = yaml.safe_load(defaults_yaml)
|
||||
|
||||
# defaults should be a non-empty dict
|
||||
assert isinstance(defaults, dict)
|
||||
assert defaults, "Expected non-empty defaults for php.ini sample"
|
||||
|
||||
# all keys should be lowercase, start with prefix, and have no spaces
|
||||
for key in defaults:
|
||||
assert key.startswith("php_")
|
||||
assert key == key.lower()
|
||||
assert " " not in key
|
||||
|
||||
# template generation
|
||||
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 template.strip(), "Template for php.ini sample should not be empty"
|
||||
|
||||
# each default variable name should appear in the template as a Jinja placeholder
|
||||
for var_name in defaults:
|
||||
assert (
|
||||
var_name in template
|
||||
), f"Variable {var_name} not referenced in INI template"
|
||||
|
||||
|
||||
def test_generate_template_fallback_ini():
|
||||
"""
|
||||
When original_text is not provided, generate_template should use the
|
||||
structural fallback path for INI configs.
|
||||
"""
|
||||
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"
|
||||
handler = IniHandler()
|
||||
tmpl = handler._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_ini_handler_flatten_type_error():
|
||||
"""
|
||||
Passing a non-ConfigParser into IniHandler.flatten should raise TypeError.
|
||||
"""
|
||||
handler = IniHandler()
|
||||
with pytest.raises(TypeError):
|
||||
handler.flatten(parsed={"not": "a configparser"})
|
||||
56
tests/test_json_handler.py
Normal file
56
tests/test_json_handler.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import json
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from jinjaturtle.core import (
|
||||
parse_config,
|
||||
flatten_config,
|
||||
generate_defaults_yaml,
|
||||
)
|
||||
from jinjaturtle.handlers.json import JsonHandler
|
||||
|
||||
SAMPLES_DIR = Path(__file__).parent / "samples"
|
||||
|
||||
|
||||
def test_json_roundtrip():
|
||||
json_path = SAMPLES_DIR / "foo.json"
|
||||
assert json_path.is_file(), f"Missing sample JSON file: {json_path}"
|
||||
|
||||
fmt, parsed = parse_config(json_path)
|
||||
assert fmt == "json"
|
||||
|
||||
flat_items = flatten_config(fmt, parsed)
|
||||
defaults_yaml = generate_defaults_yaml("foobar", flat_items)
|
||||
defaults = yaml.safe_load(defaults_yaml)
|
||||
|
||||
# Defaults: nested keys and list indices
|
||||
assert defaults["foobar_foo"] == "bar"
|
||||
assert defaults["foobar_nested_a"] == 1
|
||||
# Bool normalized to string "true"
|
||||
assert defaults["foobar_nested_b"] == "true"
|
||||
assert defaults["foobar_list_0"] == 10
|
||||
assert defaults["foobar_list_1"] == 20
|
||||
|
||||
# Template generation is done via JsonHandler.generate_template; we just
|
||||
# make sure it produces a structure with the expected placeholders.
|
||||
handler = JsonHandler()
|
||||
templated = json.loads(handler.generate_template(parsed, role_prefix="foobar"))
|
||||
|
||||
assert templated["foo"] == "{{ foobar_foo }}"
|
||||
assert "foobar_nested_a" in str(templated)
|
||||
assert "foobar_nested_b" in str(templated)
|
||||
assert "foobar_list_0" in str(templated)
|
||||
assert "foobar_list_1" in str(templated)
|
||||
|
||||
|
||||
def test_generate_template_json_type_error():
|
||||
"""
|
||||
Wrong type for JSON in JsonHandler.generate_template should raise TypeError.
|
||||
"""
|
||||
handler = JsonHandler()
|
||||
with pytest.raises(TypeError):
|
||||
handler.generate_template(parsed="not a dict", role_prefix="role")
|
||||
114
tests/test_toml_handler.py
Normal file
114
tests/test_toml_handler.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from jinjaturtle.core import (
|
||||
parse_config,
|
||||
flatten_config,
|
||||
generate_defaults_yaml,
|
||||
generate_template,
|
||||
)
|
||||
from jinjaturtle.handlers.toml import TomlHandler
|
||||
import jinjaturtle.handlers.toml as toml_module
|
||||
|
||||
SAMPLES_DIR = Path(__file__).parent / "samples"
|
||||
|
||||
|
||||
def test_toml_sample_roundtrip():
|
||||
toml_path = SAMPLES_DIR / "tom.toml"
|
||||
assert toml_path.is_file(), f"Missing sample TOML file: {toml_path}"
|
||||
|
||||
fmt, parsed = parse_config(toml_path)
|
||||
assert fmt == "toml"
|
||||
|
||||
flat_items = flatten_config(fmt, parsed)
|
||||
assert flat_items
|
||||
|
||||
defaults_yaml = generate_defaults_yaml("jinjaturtle", flat_items)
|
||||
defaults = yaml.safe_load(defaults_yaml)
|
||||
|
||||
# defaults should be a non-empty dict
|
||||
assert isinstance(defaults, dict)
|
||||
assert defaults, "Expected non-empty defaults for TOML sample"
|
||||
|
||||
# all keys should be lowercase, start with prefix, and have no spaces
|
||||
for key in defaults:
|
||||
assert key.startswith("jinjaturtle_")
|
||||
assert key == key.lower()
|
||||
assert " " not in key
|
||||
|
||||
# template generation – **now with original_text**
|
||||
original_text = toml_path.read_text(encoding="utf-8")
|
||||
template = generate_template(
|
||||
fmt, parsed, "jinjaturtle", original_text=original_text
|
||||
)
|
||||
assert isinstance(template, str)
|
||||
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
|
||||
for var_name in defaults:
|
||||
assert (
|
||||
var_name in template
|
||||
), f"Variable {var_name} not referenced in TOML template"
|
||||
|
||||
|
||||
def test_parse_config_toml_missing_tomllib(monkeypatch):
|
||||
"""
|
||||
Force tomllib to None to hit the RuntimeError branch when parsing TOML.
|
||||
"""
|
||||
toml_path = SAMPLES_DIR / "tom.toml"
|
||||
|
||||
# Simulate an environment without tomllib/tomli
|
||||
monkeypatch.setattr(toml_module, "tomllib", None)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc:
|
||||
parse_config(toml_path, fmt="toml")
|
||||
assert "tomllib/tomli is required" in str(exc.value)
|
||||
|
||||
|
||||
def test_generate_template_fallback_toml():
|
||||
"""
|
||||
When original_text is not provided, generate_template should use the
|
||||
structural fallback path for TOML configs.
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
handler = TomlHandler()
|
||||
tmpl = handler._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
|
||||
230
tests/test_xml_handler.py
Normal file
230
tests/test_xml_handler.py
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import textwrap
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from jinjaturtle.core import (
|
||||
parse_config,
|
||||
flatten_config,
|
||||
generate_defaults_yaml,
|
||||
generate_template,
|
||||
)
|
||||
from jinjaturtle.handlers.xml import XmlHandler
|
||||
|
||||
SAMPLES_DIR = Path(__file__).parent / "samples"
|
||||
|
||||
|
||||
def test_xml_roundtrip_ossec_web_rules():
|
||||
xml_path = SAMPLES_DIR / "ossec.xml"
|
||||
assert xml_path.is_file(), f"Missing sample XML file: {xml_path}"
|
||||
|
||||
fmt, parsed = parse_config(xml_path)
|
||||
assert fmt == "xml"
|
||||
|
||||
flat_items = flatten_config(fmt, parsed)
|
||||
assert flat_items, "Expected at least one flattened item from XML sample"
|
||||
|
||||
defaults_yaml = generate_defaults_yaml("ossec", flat_items)
|
||||
defaults = yaml.safe_load(defaults_yaml)
|
||||
|
||||
# defaults should be a non-empty dict
|
||||
assert isinstance(defaults, dict)
|
||||
assert defaults, "Expected non-empty defaults for XML sample"
|
||||
|
||||
# all keys should be lowercase, start with prefix, and have no spaces
|
||||
for key in defaults:
|
||||
assert key.startswith("ossec_")
|
||||
assert key == key.lower()
|
||||
assert " " not in key
|
||||
|
||||
# Root <group name="web,accesslog,"> attribute should flatten to ossec_name
|
||||
assert defaults["ossec_name"] == "web,accesslog,"
|
||||
|
||||
# There should be at least one default for rule id="31100"
|
||||
id_keys = [k for k, v in defaults.items() if v == "31100"]
|
||||
assert id_keys, "Expected to find a default for rule id 31100"
|
||||
|
||||
# At least one of them should be the rule *id* attribute
|
||||
assert any(
|
||||
key.startswith("ossec_rule_") and key.endswith("_id") for key in id_keys
|
||||
), f"Expected at least one *_id var for value 31100, got: {id_keys}"
|
||||
|
||||
# Template generation (preserving comments)
|
||||
original_text = xml_path.read_text(encoding="utf-8")
|
||||
template = generate_template(fmt, parsed, "ossec", original_text=original_text)
|
||||
assert isinstance(template, str)
|
||||
assert template.strip(), "Template for XML sample should not be empty"
|
||||
|
||||
# Top-of-file and mid-file comments should be preserved
|
||||
assert "Official Web access rules for OSSEC." in template
|
||||
assert "Rules to ignore crawlers" in template
|
||||
|
||||
# Each default variable name should appear in the template as a Jinja placeholder
|
||||
for var_name in defaults:
|
||||
assert (
|
||||
var_name in template
|
||||
), f"Variable {var_name} not referenced in XML template"
|
||||
|
||||
|
||||
def test_generate_xml_template_from_text_edge_cases():
|
||||
"""
|
||||
Exercise XML text edge cases:
|
||||
- XML declaration and DOCTYPE in prolog
|
||||
- top-level and inner comments
|
||||
- repeated child elements (indexing)
|
||||
- attributes and text content
|
||||
"""
|
||||
text = textwrap.dedent(
|
||||
"""\
|
||||
<?xml version="1.0"?>
|
||||
<!-- top comment -->
|
||||
<!DOCTYPE something>
|
||||
<root attr="1">
|
||||
<!-- inner comment -->
|
||||
<child attr="2">text</child>
|
||||
<child>other</child>
|
||||
</root>
|
||||
"""
|
||||
)
|
||||
|
||||
handler = XmlHandler()
|
||||
tmpl = handler._generate_xml_template_from_text("role", text)
|
||||
|
||||
# Prolog and comments preserved
|
||||
assert "<?xml version" in tmpl
|
||||
assert "top comment" in tmpl
|
||||
assert "inner comment" in tmpl
|
||||
|
||||
# Root attribute becomes a variable (path ("@attr",) -> role_attr)
|
||||
assert "role_attr" in tmpl
|
||||
|
||||
# Repeated <child> elements should be indexed in both attr and text
|
||||
assert "role_child_0_attr" in tmpl
|
||||
assert "role_child_0" in tmpl
|
||||
assert "role_child_1" in tmpl
|
||||
|
||||
|
||||
def test_generate_template_xml_type_error():
|
||||
"""
|
||||
Wrong type for XML in XmlHandler.generate_template should raise TypeError.
|
||||
"""
|
||||
handler = XmlHandler()
|
||||
with pytest.raises(TypeError):
|
||||
handler.generate_template(parsed="not an element", role_prefix="role")
|
||||
|
||||
|
||||
def test_flatten_config_xml_type_error():
|
||||
"""
|
||||
Wrong type for XML in flatten_config should raise TypeError.
|
||||
"""
|
||||
with pytest.raises(TypeError):
|
||||
flatten_config("xml", parsed="not-an-element")
|
||||
|
||||
|
||||
def test_generate_template_xml_structural_fallback():
|
||||
"""
|
||||
When original_text is not provided for XML, generate_template should use
|
||||
the structural fallback path (ET.tostring + handler processing).
|
||||
"""
|
||||
xml_text = textwrap.dedent(
|
||||
"""\
|
||||
<root attr="1">
|
||||
<child>2</child>
|
||||
<node attr="x">text</node>
|
||||
</root>
|
||||
"""
|
||||
)
|
||||
root = ET.fromstring(xml_text)
|
||||
|
||||
tmpl = generate_template("xml", parsed=root, role_prefix="role")
|
||||
|
||||
# Root attribute path ("@attr",) -> role_attr
|
||||
assert "role_attr" in tmpl
|
||||
|
||||
# Simple child element text ("child",) -> role_child
|
||||
assert "role_child" in tmpl
|
||||
|
||||
# Element with both attr and text:
|
||||
# - attr -> ("node", "@attr") -> role_node_attr
|
||||
# - text -> ("node", "value") -> role_node_value
|
||||
assert "role_node_attr" in tmpl
|
||||
assert "role_node_value" in tmpl
|
||||
|
||||
|
||||
def test_split_xml_prolog_only_whitespace():
|
||||
"""
|
||||
Whitespace-only input: prolog is the whitespace, body is empty.
|
||||
Exercises the 'if i >= n: break' path.
|
||||
"""
|
||||
text = " \n\t"
|
||||
handler = XmlHandler()
|
||||
prolog, body = handler._split_xml_prolog(text)
|
||||
assert prolog == text
|
||||
assert body == ""
|
||||
|
||||
|
||||
def test_split_xml_prolog_unterminated_declaration():
|
||||
"""
|
||||
Unterminated XML declaration should hit the 'end == -1' branch and
|
||||
treat the whole string as body.
|
||||
"""
|
||||
text = "<?xml version='1.0'"
|
||||
handler = XmlHandler()
|
||||
prolog, body = handler._split_xml_prolog(text)
|
||||
assert prolog == ""
|
||||
assert body == text
|
||||
|
||||
|
||||
def test_split_xml_prolog_unterminated_comment():
|
||||
"""
|
||||
Unterminated comment should likewise hit its 'end == -1' branch.
|
||||
"""
|
||||
text = "<!-- no end"
|
||||
handler = XmlHandler()
|
||||
prolog, body = handler._split_xml_prolog(text)
|
||||
assert prolog == ""
|
||||
assert body == text
|
||||
|
||||
|
||||
def test_split_xml_prolog_unterminated_doctype():
|
||||
"""
|
||||
Unterminated DOCTYPE should hit the DOCTYPE 'end == -1' branch.
|
||||
"""
|
||||
text = "<!DOCTYPE foo"
|
||||
handler = XmlHandler()
|
||||
prolog, body = handler._split_xml_prolog(text)
|
||||
assert prolog == ""
|
||||
assert body == text
|
||||
|
||||
|
||||
def test_split_xml_prolog_unexpected_content():
|
||||
"""
|
||||
Non-XML content at the start should trigger the 'unexpected content'
|
||||
break and be returned entirely as body.
|
||||
"""
|
||||
text = "garbage<root/>"
|
||||
handler = XmlHandler()
|
||||
prolog, body = handler._split_xml_prolog(text)
|
||||
assert prolog == ""
|
||||
assert body == text
|
||||
|
||||
|
||||
def test_flatten_xml_text_with_attributes_uses_value_suffix():
|
||||
"""
|
||||
When an element has both attributes and text, _flatten_xml should store
|
||||
the text at path + ('value',), not just path.
|
||||
"""
|
||||
xml_text = "<root><node attr='x'>text</node></root>"
|
||||
root = ET.fromstring(xml_text)
|
||||
|
||||
items = flatten_config("xml", root)
|
||||
|
||||
# Attribute path: ("node", "@attr") -> "x"
|
||||
assert (("node", "@attr"), "x") in items
|
||||
|
||||
# Text-with-attrs path: ("node", "value") -> "text"
|
||||
assert (("node", "value"), "text") in items
|
||||
100
tests/test_yaml_handler.py
Normal file
100
tests/test_yaml_handler.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import textwrap
|
||||
|
||||
import yaml
|
||||
|
||||
from jinjaturtle.core import (
|
||||
parse_config,
|
||||
flatten_config,
|
||||
generate_defaults_yaml,
|
||||
generate_template,
|
||||
)
|
||||
from jinjaturtle.handlers.yaml import YamlHandler
|
||||
|
||||
SAMPLES_DIR = Path(__file__).parent / "samples"
|
||||
|
||||
|
||||
def test_yaml_roundtrip_with_list_and_comment():
|
||||
yaml_path = SAMPLES_DIR / "bar.yaml"
|
||||
assert yaml_path.is_file(), f"Missing sample YAML file: {yaml_path}"
|
||||
|
||||
fmt, parsed = parse_config(yaml_path)
|
||||
assert fmt == "yaml"
|
||||
|
||||
flat_items = flatten_config(fmt, parsed)
|
||||
defaults_yaml = generate_defaults_yaml("foobar", flat_items)
|
||||
defaults = yaml.safe_load(defaults_yaml)
|
||||
|
||||
# Defaults: keys are flattened with indices
|
||||
assert defaults["foobar_foo"] == "bar"
|
||||
assert defaults["foobar_blah_0"] == "something"
|
||||
assert defaults["foobar_blah_1"] == "else"
|
||||
|
||||
# Template generation (preserving comments)
|
||||
original_text = yaml_path.read_text(encoding="utf-8")
|
||||
template = generate_template(fmt, parsed, "foobar", original_text=original_text)
|
||||
|
||||
# Comment preserved
|
||||
assert "# Top comment" in template
|
||||
|
||||
# Scalar replacement
|
||||
assert "foo:" in template
|
||||
assert "foobar_foo" in template
|
||||
|
||||
# List items use indexed vars, not "item"
|
||||
assert "foobar_blah_0" in template
|
||||
assert "foobar_blah_1" in template
|
||||
assert "{{ foobar_blah }}" not in template
|
||||
assert "foobar_blah_item" not 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
|
||||
"""
|
||||
)
|
||||
|
||||
handler = YamlHandler()
|
||||
tmpl = handler._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 + handler processing).
|
||||
"""
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue