diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index b810b16..1bb738d 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -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,
diff --git a/json2xml/dicttoxml.py b/json2xml/dicttoxml.py
index c6def5c..2e2321c 100644
--- a/json2xml/dicttoxml.py
+++ b/json2xml/dicttoxml.py
@@ -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":
@@ -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''
+ 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)
@@ -902,10 +927,11 @@ def dicttoxml(
namespace_str += f' xmlns:{prefix}="{ns}"'
if root:
output.append('')
+ 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)
diff --git a/lat.md/tests.md b/lat.md/tests.md
index cd746bb..86e3f6c 100644
--- a/lat.md/tests.md
+++ b/lat.md/tests.md
@@ -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 `- ` 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 `` 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.
@@ -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.
diff --git a/tests/test_dict2xml.py b/tests/test_dict2xml.py
index b81726b..13d9277 100644
--- a/tests/test_dict2xml.py
+++ b/tests/test_dict2xml.py
@@ -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"value" 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''
+ b"value"
+ )
+
+ # @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,
+ )
+
+ @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'payload'.encode()
+
def test_dicttoxml_with_xml_namespaces(self) -> None:
"""Test dicttoxml with XML namespaces."""
data = {"key": "value"}
diff --git a/tests/test_dicttoxml_unit.py b/tests/test_dicttoxml_unit.py
index 3e92dcb..91d0a40 100644
--- a/tests/test_dicttoxml_unit.py
+++ b/tests/test_dicttoxml_unit.py
@@ -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"})