Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pythonpackage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: [pypy-3.10, pypy-3.11, '3.10', '3.11', '3.12', '3.13', '3.14', '3.14t', '3.15.0-alpha.7']
python-version: [pypy-3.10, pypy-3.11, '3.10', '3.11', '3.12', '3.13', '3.14', '3.14t', '3.15.0-beta.2']
os: [
ubuntu-latest,
windows-latest,
Expand Down
28 changes: 27 additions & 1 deletion json2xml/dicttoxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ def make_attrstring(attr: dict[str, Any]) -> str:
"""
if not attr:
return ""
validate_xml_attr_names(attr)
if len(attr) == 1:
key, val = next(iter(attr.items()))
if key == "type":
Expand Down Expand Up @@ -183,6 +184,30 @@ def key_is_valid_xml(key: str) -> bool:
return False


@lru_cache(maxsize=4096)
def key_is_valid_xml_attr(key: str) -> bool:
"""Return True when key can be emitted directly as an XML attribute name."""
key = str(key)
if not key:
return False

from defusedxml.minidom import parseString

test_xml = f'<?xml version="1.0" encoding="UTF-8" ?><root {key}="value"></root>'
try:
parseString(test_xml)
return True
except Exception: # minidom does not implement exceptions well
return False


def validate_xml_attr_names(attr: dict[str, Any]) -> None:
"""Reject attributes that would make the generated XML malformed."""
for key in attr:
if not key_is_valid_xml_attr(key):
raise ValueError(f"Invalid XML attribute name: {key}")


def make_valid_xml_name(key: str, attr: dict[str, Any]) -> tuple[str, dict[str, Any]]:
"""Return a valid XML element name and carry the original key as metadata when needed."""
key = str(key)
Expand Down Expand Up @@ -902,10 +927,11 @@ def dicttoxml(
namespace_str += f' xmlns:{prefix}="{ns}"'
if root:
output.append('<?xml version="1.0" encoding="UTF-8" ?>')
custom_root, root_attr = make_valid_xml_name(custom_root, {})
output_elem = convert(
obj, ids, attr_type, item_func, cdata, item_wrap, parent=custom_root, list_headers=list_headers
)
output.append(f"<{custom_root}{namespace_str}>{output_elem}</{custom_root}>")
output.append(f"<{custom_root}{make_attrstring(root_attr)}{namespace_str}>{output_elem}</{custom_root}>")
else:
output.append(
convert(obj, ids, attr_type, item_func, cdata, item_wrap, parent="", list_headers=list_headers)
Expand Down
12 changes: 12 additions & 0 deletions lat.md/tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ Special dictionary keys such as `@attrs` and `@val` should bypass the Rust calla

Root scalar payloads should bypass the Rust callable until the accelerator preserves the legacy Python `<item>` wrapper shape under the configured root element.

### Custom root names normalize before raw output

Invalid custom root names should use the serializer's existing XML-name normalization before raw bytes are returned so `pretty=False` cannot emit malformed root tags.

### Invalid custom attributes are rejected

Custom `@attrs` keys that are not valid XML attribute names should fail explicitly because attributes have no safe metadata fallback equivalent to element `<key name="...">` output.

## XML helper behavior

These tests pin low-level XML helper contracts so performance refactors keep the same serializer output and caller-side mutation behavior.
Expand All @@ -125,3 +133,7 @@ Helpers that receive prevalidated XML names should add type metadata only to the
### XML name validity fast and cached paths

XML name validation should agree across the ASCII fast path, parser-backed path, and repeated cached calls so optimization does not change accepted names.

### XML attribute name validation

Attribute name validation should reject malformed custom attribute keys while preserving parser-accepted edge names such as underscores, hyphens, and xml-prefixed names.
39 changes: 39 additions & 0 deletions tests/test_dict2xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,45 @@ def test_dicttoxml_with_custom_root_missing_prefix(self) -> None:
result = dicttoxml.dicttoxml(data, custom_root="custom", attr_type=False)
assert b"<custom><key>value</key></custom>" in result

# @lat: [[tests#Conversion behavior#Custom root names normalize before raw output]]
def test_dicttoxml_normalizes_invalid_custom_root_name(self) -> None:
"""Invalid custom roots should use the same XML-name normalization as JSON object keys."""
result = dicttoxml.dicttoxml(
{"key": "value"},
custom_root="custom root",
attr_type=False,
)

assert result == (
b'<?xml version="1.0" encoding="UTF-8" ?>'
b"<custom_root><key>value</key></custom_root>"
)

# @lat: [[tests#Conversion behavior#Invalid custom attributes are rejected]]
@pytest.mark.parametrize(
"attr_name",
["", "1foo", "foo>bar", 'foo"bar', "foo\nbar", "bad attr"],
)
def test_dicttoxml_rejects_invalid_custom_attribute_names(self, attr_name: str) -> None:
"""Invalid custom attribute names should fail before dicttoxml returns malformed XML bytes."""
with pytest.raises(ValueError, match="Invalid XML attribute name"):
dicttoxml.dicttoxml(
{"key": {"@attrs": {attr_name: "value"}, "@val": "payload"}},
root=False,
attr_type=False,
)
Comment thread
vinitkumar marked this conversation as resolved.

@pytest.mark.parametrize("attr_name", ["a_b", "a-b", "xmlAttr"])
def test_dicttoxml_accepts_valid_custom_attribute_edge_names(self, attr_name: str) -> None:
"""Borderline valid custom attribute names should remain accepted."""
result = dicttoxml.dicttoxml(
{"key": {"@attrs": {attr_name: "value"}, "@val": "payload"}},
root=False,
attr_type=False,
)

assert result == f'<key {attr_name}="value">payload</key>'.encode()

def test_dicttoxml_with_xml_namespaces(self) -> None:
"""Test dicttoxml with XML namespaces."""
data = {"key": "value"}
Expand Down
27 changes: 27 additions & 0 deletions tests/test_dicttoxml_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,30 @@ def test_key_is_valid_xml_fast_and_parse_paths_are_stable_under_cache() -> None:
assert second == cases
cache_info = dicttoxml.key_is_valid_xml.cache_info()
assert cache_info.hits >= len(cases)


# @lat: [[tests#XML helper behavior#XML attribute name validation]]
def test_xml_attribute_name_validation_accepts_only_parser_valid_names() -> None:
dicttoxml.key_is_valid_xml_attr.cache_clear()

cases = {
"a_b": True,
"a-b": True,
"xmlAttr": True,
"": False,
"1foo": False,
"foo>bar": False,
'foo"bar': False,
"foo\nbar": False,
}

first = {key: dicttoxml.key_is_valid_xml_attr(key) for key in cases}
second = {key: dicttoxml.key_is_valid_xml_attr(key) for key in reversed(cases)}

assert first == cases
assert second == cases
dicttoxml.validate_xml_attr_names({key: "value" for key, is_valid in cases.items() if is_valid})
for key, is_valid in cases.items():
if not is_valid:
with pytest.raises(ValueError, match="Invalid XML attribute name"):
dicttoxml.validate_xml_attr_names({key: "value"})
Loading