From 1cbd430f8590d66e23201c09b645d5feda4f0706 Mon Sep 17 00:00:00 2001 From: Apoorv Darshan Date: Thu, 9 Jul 2026 19:35:13 +0530 Subject: [PATCH] Fix wrapper binding when collection item element name is reused Two wrapped collections on the same dataclass that reuse the same item element name (e.g. both and contain ) failed to parse. The wrapper node proxied the item to the parent by the bare item qname, so both build and bind matched the first field sharing that qname, routing every item into it and raising a TypeError for the required arguments of the mismatched item class. Scope the item lookup to the active wrapper qname: WrapperNode now knows its wrapper element and passes it down, so ElementNode filters candidate vars by their wrapper_qname when building child nodes and records the wrapper per item to disambiguate binding. Fixes #1142. --- .../dataclass/parsers/nodes/test_wrapper.py | 34 +++++++++++++ xsdata/formats/dataclass/parsers/bases.py | 2 +- .../dataclass/parsers/nodes/element.py | 49 ++++++++++++++++++- .../dataclass/parsers/nodes/wrapper.py | 6 ++- 4 files changed, 87 insertions(+), 4 deletions(-) diff --git a/tests/formats/dataclass/parsers/nodes/test_wrapper.py b/tests/formats/dataclass/parsers/nodes/test_wrapper.py index f3832ad2..d6bbe6c3 100644 --- a/tests/formats/dataclass/parsers/nodes/test_wrapper.py +++ b/tests/formats/dataclass/parsers/nodes/test_wrapper.py @@ -67,3 +67,37 @@ class ElementWrapper: self.assertIsInstance(obj.elements[1], ElementObject) self.assertEqual(obj.elements[0].content, "Hello") self.assertEqual(obj.elements[1].content, "World") + + def test_reused_item_name(self) -> None: + @dataclass + class Foo: + class Meta: + name = "Property" + + foo_id: int = field(metadata={"name": "Foo-Id", "type": "Attribute"}) + + @dataclass + class Bar: + class Meta: + name = "Property" + + bar_id: str = field(metadata={"name": "Bar-Id", "type": "Attribute"}) + + @dataclass + class Response: + foos: list[Foo] = field( + metadata={"wrapper": "Foos", "name": "Property", "type": "Element"} + ) + bars: list[Bar] = field( + metadata={"wrapper": "Bars", "name": "Property", "type": "Element"} + ) + + xml = ( + "" + '' + '' + "" + ) + obj = self.parser.from_string(xml, clazz=Response) + self.assertEqual([Foo(foo_id=1), Foo(foo_id=2)], obj.foos) + self.assertEqual([Bar(bar_id="3"), Bar(bar_id="4")], obj.bars) diff --git a/xsdata/formats/dataclass/parsers/bases.py b/xsdata/formats/dataclass/parsers/bases.py index 92e804ba..d9d9b2ff 100644 --- a/xsdata/formats/dataclass/parsers/bases.py +++ b/xsdata/formats/dataclass/parsers/bases.py @@ -89,7 +89,7 @@ def start( try: item = queue[-1] if isinstance(item, ElementNode) and qname in item.meta.wrappers: - child = cast(XmlNode, WrapperNode(parent=item)) + child = cast(XmlNode, WrapperNode(parent=item, qname=qname)) else: child = item.child(qname, attrs, ns_map, len(objects)) except IndexError: diff --git a/xsdata/formats/dataclass/parsers/nodes/element.py b/xsdata/formats/dataclass/parsers/nodes/element.py index 01de1383..bf62a22f 100644 --- a/xsdata/formats/dataclass/parsers/nodes/element.py +++ b/xsdata/formats/dataclass/parsers/nodes/element.py @@ -35,6 +35,8 @@ class ElementNode(XmlNode): Attributes: assigned: A set to store the processed sub-nodes tail_processed: Whether the tail process is consumed + wrappers: A mapping of child item qname to the queue of wrapper + qnames the items were parsed under, in document order """ __slots__ = ( @@ -48,6 +50,7 @@ class ElementNode(XmlNode): "ns_map", "position", "tail_processed", + "wrappers", "xsi_nil", "xsi_type", ) @@ -78,6 +81,11 @@ def __init__( self.xsi_nil = xsi_nil self.assigned: set[int] = set() self.tail_processed: bool = False + # Queue of wrapper qnames per child item qname, recorded in document + # order as children are built. It lets binding disambiguate sibling + # wrappers that reuse the same item element name (e.g. two wrappers + # whose items are both named ``Property``). + self.wrappers: dict[str, list[str]] = {} def bind( self, @@ -250,7 +258,11 @@ def bind_object(self, params: dict, qname: str, value: Any) -> bool: Whether the parsed object can fit in one of class parameters or not. """ + wrapper = self.pop_wrapper(qname) for var in self.meta.find_children(qname): + if wrapper and var.wrapper_qname != wrapper: + continue + if var.is_wildcard: return self.bind_wild_var(params, var, qname, value) @@ -259,6 +271,26 @@ def bind_object(self, params: dict, qname: str, value: Any) -> bool: return False + def pop_wrapper(self, qname: str) -> str | None: + """Return the wrapper qname for the next child object of the qname. + + The wrapper qnames are recorded in document order as children + are built, so popping the first one keeps binding aligned with + the parsed objects and lets sibling wrappers that reuse the same + item element name route to the correct field. + + Args: + qname: The qualified name of the child element + + Returns: + The wrapper qualified name or None if the child isn't wrapped. + """ + wrappers = self.wrappers.get(qname) + if wrappers: + return wrappers.pop(0) + + return None + @classmethod def bind_var(cls, params: dict, var: XmlVar, value: Any) -> bool: """Bind a child object to an element field. @@ -438,7 +470,14 @@ def bind_wild_text( return True - def child(self, qname: str, attrs: dict, ns_map: dict, position: int) -> XmlNode: + def child( + self, + qname: str, + attrs: dict, + ns_map: dict, + position: int, + wrapper: str | None = None, + ) -> XmlNode: """Initialize the next child node to be queued, when an element starts. This entry point is responsible to create the next node type @@ -450,11 +489,16 @@ def child(self, qname: str, attrs: dict, ns_map: dict, position: int) -> XmlNode attrs: The element attributes ns_map: The element namespace prefix-URI map position: The current length of the intermediate objects + wrapper: The qualified name of the wrapper element the child + is nested under, if any Raises: ParserError: If the child element is unknown """ for var in self.meta.find_children(qname): + if wrapper and var.wrapper_qname != wrapper: + continue + unique = 0 if not var.is_element or var.list_element else var.index if not unique or unique not in self.assigned: node = self.build_node(qname, var, attrs, ns_map, position) @@ -463,6 +507,9 @@ def child(self, qname: str, attrs: dict, ns_map: dict, position: int) -> XmlNode if unique: self.assigned.add(unique) + if wrapper: + self.wrappers.setdefault(qname, []).append(wrapper) + return node if self.config.fail_on_unknown_properties: diff --git a/xsdata/formats/dataclass/parsers/nodes/wrapper.py b/xsdata/formats/dataclass/parsers/nodes/wrapper.py index 0927b3ac..add96d7e 100644 --- a/xsdata/formats/dataclass/parsers/nodes/wrapper.py +++ b/xsdata/formats/dataclass/parsers/nodes/wrapper.py @@ -14,14 +14,16 @@ class WrapperNode(XmlNode): Args: parent: The parent node + qname: The wrapper element qualified name Attributes: ns_map: The node namespace prefix-URI map """ - def __init__(self, parent: ElementNode): + def __init__(self, parent: ElementNode, qname: str): """Initialize the xml node.""" self.parent = parent + self.qname = qname self.ns_map = parent.ns_map def bind( @@ -52,4 +54,4 @@ def child(self, qname: str, attrs: dict, ns_map: dict, position: int) -> XmlNode Returns: The child xml node instance. """ - return self.parent.child(qname, attrs, ns_map, position) + return self.parent.child(qname, attrs, ns_map, position, wrapper=self.qname)