Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Wrapping a list of element and primitive nodes #710

Merged
merged 12 commits into from
Sep 28, 2022
1 change: 1 addition & 0 deletions docs/api/xml-nodes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ for models and their fields.
UnionNode
PrimitiveNode
StandardNode
WrapperNode
1 change: 1 addition & 0 deletions docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Advance Topics

examples/custom-property-names
examples/custom-class-factory
examples/wrapped-list


Test Suites
Expand Down
46 changes: 46 additions & 0 deletions docs/examples/wrapped-list.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
============
Wrapped List
============

XML data structures commonly wrap element and primitive collections.
For instance, a library may have several books and and other stuff as well.
In terms of `OpenAPI 3 <https://swagger.io/specification/#xml-object>`_,
these data structures are `wrapped`. Hence, xsdata has the field parameter `wrapper`,
which wraps any element/primitive collection into a custom xml element without the
need of a dedicated wrapper class.

.. doctest::

>>> from dataclasses import dataclass, field
>>> from typing import List
>>> from xsdata.formats.dataclass.serializers import XmlSerializer
>>> from xsdata.formats.dataclass.serializers.config import SerializerConfig
>>>
>>> config = SerializerConfig(pretty_print=True, xml_declaration=False)
>>> serializer = XmlSerializer(config=config)
>>>
>>> @dataclass
... class Library:
... books: List[str] = field(
... metadata={
... "wrapper": "Books",
... "name": "Title",
... "type": "Element",
... }
... )
...
>>> obj = Library(
... books = [
... "python for beginners",
... "beautiful xml",
... ]
... )
>>>
>>> print(serializer.render(obj))
<Library>
<Books>
<Title>python for beginners</Title>
<Title>beautiful xml</Title>
</Books>
</Library>
<BLANKLINE>
4 changes: 4 additions & 0 deletions docs/models.rst
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,10 @@ marshalling.
* - default_factory
- Any
- Default value factory
* - wrapper
- str
- The element name to wrap a collection of elements or primitives


.. warning::

Expand Down
39 changes: 39 additions & 0 deletions tests/formats/dataclass/models/test_builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from typing import get_type_hints
from typing import Iterator
from typing import List
from typing import Tuple
from typing import Union
from unittest import mock
from unittest import TestCase
Expand All @@ -27,6 +28,7 @@
from xsdata.formats.dataclass.compat import class_types
from xsdata.formats.dataclass.models.builders import XmlMetaBuilder
from xsdata.formats.dataclass.models.builders import XmlVarBuilder
from xsdata.formats.dataclass.models.elements import XmlMeta
from xsdata.formats.dataclass.models.elements import XmlType
from xsdata.models.datatype import XmlDate
from xsdata.utils import text
Expand Down Expand Up @@ -103,6 +105,43 @@ class Meta:
result = self.builder.build(Thug, None)
self.assertEqual("thug", result.qname)

def test_wrapper(self):
@dataclass
class PrimitiveType:
attr: str = field(metadata={"wrapper": "Items"})

@dataclass
class UnionType:
attr: Union[str, int] = field(metadata={"wrapper": "Items"})

@dataclass
class UnionCollection:
union_collection: List[Union[str, int]] = field(
metadata={"wrapper": "Items"}
)

@dataclass
class ListType:
attr: List[str] = field(metadata={"wrapper": "Items"})

@dataclass
class TupleType:
attr: Tuple[str, ...] = field(metadata={"wrapper": "Items"})

# @dataclass
# class SetType:
# attr: Set[str] = field(metadata={"wrapper": "Items"})

with self.assertRaises(XmlContextError):
self.builder.build(PrimitiveType, None)
with self.assertRaises(XmlContextError):
self.builder.build(UnionType, None)

self.assertIsInstance(self.builder.build(ListType, None), XmlMeta)
self.assertIsInstance(self.builder.build(TupleType, None), XmlMeta)
# not supported by analyze_types
# self.assertIsInstance(self.builder.build(SetType, None), XmlMeta)

def test_build_with_no_dataclass_raises_exception(self, *args):
with self.assertRaises(XmlContextError) as cm:
self.builder.build(int, None)
Expand Down
71 changes: 71 additions & 0 deletions tests/formats/dataclass/parsers/nodes/test_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from dataclasses import dataclass
from dataclasses import field
from typing import List
from unittest import TestCase

from xsdata.formats.dataclass.parsers import XmlParser


class WrapperTests(TestCase):
def setUp(self) -> None:
self.parser = XmlParser()

def test_namespace(self):
@dataclass
class NamespaceWrapper:
items: List[str] = field(
metadata={
"wrapper": "Items",
"type": "Element",
"name": "item",
"namespace": "ns",
}
)

xml = '<NamespaceWrapper xmlns:foo="ns"><foo:Items><foo:item>a</foo:item><foo:item>b</foo:item></foo:Items></NamespaceWrapper>'
obj = self.parser.from_string(xml, clazz=NamespaceWrapper)
self.assertIsInstance(obj, NamespaceWrapper)
self.assertTrue(hasattr(obj, "items"))
self.assertEqual(len(obj.items), 2)
self.assertEqual(obj.items[0], "a")
self.assertEqual(obj.items[1], "b")

def test_primitive(self):
@dataclass
class PrimitiveWrapper:
primitive_list: List[str] = field(
metadata={
"wrapper": "PrimitiveList",
"type": "Element",
"name": "Value",
}
)

xml = r"<PrimitiveWrapper><PrimitiveList><Value>Value 1</Value><Value>Value 2</Value></PrimitiveList></PrimitiveWrapper>"
obj = self.parser.from_string(xml, clazz=PrimitiveWrapper)
self.assertTrue(hasattr(obj, "primitive_list"))
self.assertIsInstance(obj.primitive_list, list)
self.assertEqual(len(obj.primitive_list), 2)
self.assertEqual(obj.primitive_list[0], "Value 1")
self.assertEqual(obj.primitive_list[1], "Value 2")

def test_element(self):
@dataclass
class ElementObject:
content: str = field(metadata={"type": "Element"})

@dataclass
class ElementWrapper:
elements: List[ElementObject] = field(
metadata={"wrapper": "Elements", "type": "Element", "name": "Object"}
)

xml = "<ElementWrapper><Elements><Object><content>Hello</content></Object><Object><content>World</content></Object></Elements></ElementWrapper>"
obj = self.parser.from_string(xml, clazz=ElementWrapper)
self.assertTrue(hasattr(obj, "elements"))
self.assertIsInstance(obj.elements, list)
self.assertEqual(len(obj.elements), 2)
self.assertIsInstance(obj.elements[0], ElementObject)
self.assertIsInstance(obj.elements[1], ElementObject)
self.assertEqual(obj.elements[0].content, "Hello")
self.assertEqual(obj.elements[1].content, "World")
56 changes: 56 additions & 0 deletions tests/formats/dataclass/serializers/test_xml.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import re
from dataclasses import dataclass
from dataclasses import field
from dataclasses import make_dataclass
from typing import Generator
from typing import List
from unittest import TestCase
from xml.etree.ElementTree import QName

Expand All @@ -25,6 +29,58 @@ class XmlSerializerTests(TestCase):
def setUp(self) -> None:
self.serializer = XmlSerializer()

def test_wrapper_primitive(self):
@dataclass
class PrimitiveWrapper:
primitive_list: List[str] = field(
metadata={
"wrapper": "PrimitiveList",
"type": "Element",
"name": "Value",
}
)

obj = PrimitiveWrapper(primitive_list=["Value 1", "Value 2"])
xml = self.serializer.render(obj)
expected = r"<PrimitiveWrapper><PrimitiveList><Value>Value 1</Value><Value>Value 2</Value></PrimitiveList></PrimitiveWrapper>"
self.assertIsNotNone(re.search(expected, xml))

def test_wrapper_element(self):
@dataclass
class ElementObject:
content: str = field(metadata={"type": "Element"})

@dataclass
class ElementWrapper:
elements: List[ElementObject] = field(
metadata={"wrapper": "Elements", "type": "Element", "name": "Object"}
)

obj = ElementWrapper(
elements=[ElementObject(content="Hello"), ElementObject(content="World")]
)
xml = self.serializer.render(obj)
expected = "<ElementWrapper><Elements><Object><content>Hello</content></Object><Object><content>World</content></Object></Elements></ElementWrapper>"
self.assertIsNotNone(re.search(expected, xml))

def test_wrapper_namespace(self):
@dataclass
class NamespaceWrapper:
items: List[str] = field(
metadata={
"wrapper": "Items",
"type": "Element",
"name": "item",
"namespace": "ns",
}
)

ns_map = {"foo": "ns"}
obj = NamespaceWrapper(items=["a", "b"])
xml = self.serializer.render(obj, ns_map=ns_map)
expected = '<NamespaceWrapper xmlns:foo="ns"><foo:Items><foo:item>a</foo:item><foo:item>b</foo:item></foo:Items></NamespaceWrapper>'
self.assertIsNotNone(re.search(expected, xml))

def test_write_object_with_derived_element(self):
book = BookForm(id="123")
obj = DerivedElement(qname="item", value=book)
Expand Down
1 change: 1 addition & 0 deletions tests/formats/dataclass/test_elements.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ def test__repr__(self):
"wildcards=[], "
"attributes={}, "
"any_attributes=[], "
"wrappers={}, "
"namespace=None, "
"mixed_content=False)"
)
Expand Down
20 changes: 14 additions & 6 deletions xsdata/formats/dataclass/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,21 @@ def find_type_by_fields(self, field_names: Set[str]) -> Optional[Type[T]]:
:param field_names: A unique list of field names
"""

self.build_xsi_cache()
for types in self.xsi_cache.values():
for clazz in types:
if self.local_names_match(field_names, clazz):
return clazz
def get_field_diff(clazz: Type) -> int:
meta = self.cache[clazz]
local_names = {var.local_name for var in meta.get_all_vars()}
return len(local_names - field_names)

return None
self.build_xsi_cache()
choices = [
(clazz, get_field_diff(clazz))
for types in self.xsi_cache.values()
for clazz in types
if self.local_names_match(field_names, clazz)
]

choices.sort(key=lambda x: (x[1], x[0].__name__))
return choices[0][0] if len(choices) > 0 else None

def find_subclass(self, clazz: Type, qname: str) -> Optional[Type]:
"""
Expand Down
16 changes: 16 additions & 0 deletions xsdata/formats/dataclass/models/builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,12 @@ def build(self, clazz: Type, parent_namespace: Optional[str]) -> XmlMeta:
choices = []
any_attributes = []
wildcards = []
wrappers: Dict[str, List[XmlVar]] = defaultdict(list)
text = None

for var in class_vars:
if var.wrapper is not None:
wrappers[var.wrapper].append(var)
if var.is_attribute:
attributes[var.qname] = var
elif var.is_element:
Expand All @@ -98,6 +101,7 @@ def build(self, clazz: Type, parent_namespace: Optional[str]) -> XmlMeta:
choices=choices,
any_attributes=any_attributes,
wildcards=wildcards,
wrappers=wrappers,
)

def build_vars(
Expand Down Expand Up @@ -269,6 +273,7 @@ def build(
nillable = metadata.get("nillable", False)
format_str = metadata.get("format", None)
sequential = metadata.get("sequential", False)
wrapper = metadata.get("wrapper", None)

origin, sub_origin, types = self.analyze_types(type_hint, globalns)

Expand All @@ -277,6 +282,14 @@ def build(
f"Xml type '{xml_type}' does not support typing: {type_hint}"
)

if wrapper is not None:
if not isinstance(origin, type) or not issubclass(
origin, (list, set, tuple)
):
raise XmlContextError(
f"a wrapper requires a collection type on attribute {name}"
)

local_name = self.build_local_name(xml_type, local_name, name)

if tokens and sub_origin is None:
Expand All @@ -291,6 +304,8 @@ def build(
namespaces = self.resolve_namespaces(xml_type, namespace, parent_namespace)
default_namespace = self.default_namespace(namespaces)
qname = build_qname(default_namespace, local_name)
if wrapper is not None:
wrapper = build_qname(default_namespace, wrapper)

elements = {}
wildcards = []
Expand Down Expand Up @@ -323,6 +338,7 @@ def build(
namespaces=namespaces,
xml_type=xml_type,
derived=False,
wrapper=wrapper,
)

def build_choices(
Expand Down
Loading