Skip to content

Commit

Permalink
feat: Add xml and lxml tree serializers
Browse files Browse the repository at this point in the history
Resolves #849
  • Loading branch information
tefra committed Mar 11, 2024
1 parent 6bbc299 commit 6d71a64
Show file tree
Hide file tree
Showing 11 changed files with 264 additions and 0 deletions.
57 changes: 57 additions & 0 deletions docs/data_binding/tree_serializing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Element Tree Serializer

The element tree serializers will render an object into an element tree, that you can
use to run XPATH evaluations or XSLT transformations.

There are two implementations based on lxml
[LxmlTreeSerializer][xsdata.formats.dataclass.serializers.LxmlTreeSerializer] and native
python [XmlTreeSerializer][xsdata.formats.dataclass.serializers.XmlTreeSerializer].

## xml.etree.ElementTree.Element

```python
>>> from xml.etree import ElementTree
>>> from tests.fixtures.books.fixtures import books
>>> from xsdata.formats.dataclass.serializers import XmlTreeSerializer
...
>>> serializer = XmlTreeSerializer()
>>> result = serializer.render(books)
...
>>> result.find(".//title").text
'The First Book'

```

## lxml.etree.Element

```python
>>> from lxml import etree
>>> from tests.fixtures.books.fixtures import books
>>> from xsdata.formats.dataclass.serializers import LxmlTreeSerializer
...
>>> serializer = LxmlTreeSerializer()
>>> result = serializer.render(books)
...
>>> etree.indent(result)
>>> actual = etree.tostring(result)
>>> print(actual.decode())
<ns0:books xmlns:ns0="urn:books">
<book>
<author>Hightower, Kim</author>
<title>The First Book</title>
<genre>Fiction</genre>
<price>44.95</price>
<pub_date>2000-10-01</pub_date>
<review>An amazing story of nothing.</review>
</book>
<book>
<author>Nagata, Suanne</author>
<title>Becoming Somebody</title>
<genre>Biography</genre>
<price>33.95</price>
<pub_date>2001-01-10</pub_date>
<review>A masterpiece of the fine art of gossiping.</review>
</book>
</ns0:books>

```
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ nav:
- JSON Parsing: data_binding/json_parsing.md
- JSON Serializing: data_binding/json_serializing.md
- Pycode Serializing: data_binding/pycode_serializing.md
- Tree Serializing: data_binding/tree_serializing.md
- Dict Decoding: data_binding/dict_decoding.md
- Dict Encoding: data_binding/dict_encoding.md
- FAQ: faq.md
Expand Down
Empty file.
36 changes: 36 additions & 0 deletions tests/formats/dataclass/serializers/tree/test_lxml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from unittest import TestCase

from lxml import etree

from tests.fixtures.books.fixtures import books
from xsdata.formats.dataclass.serializers import LxmlTreeSerializer


class LxmlTreeSerializerTests(TestCase):
def test_render(self):
serializer = LxmlTreeSerializer()
result = serializer.render(books)

etree.indent(result)
actual = etree.tostring(result)
expected = (
'<ns0:books xmlns:ns0="urn:books">\n'
" <book>\n"
" <author>Hightower, Kim</author>\n"
" <title>The First Book</title>\n"
" <genre>Fiction</genre>\n"
" <price>44.95</price>\n"
" <pub_date>2000-10-01</pub_date>\n"
" <review>An amazing story of nothing.</review>\n"
" </book>\n"
" <book>\n"
" <author>Nagata, Suanne</author>\n"
" <title>Becoming Somebody</title>\n"
" <genre>Biography</genre>\n"
" <price>33.95</price>\n"
" <pub_date>2001-01-10</pub_date>\n"
" <review>A masterpiece of the fine art of gossiping.</review>\n"
" </book>\n"
"</ns0:books>"
)
self.assertEqual(expected, actual.decode())
21 changes: 21 additions & 0 deletions tests/formats/dataclass/serializers/tree/test_mixins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from unittest import TestCase, mock
from xml.etree.ElementTree import TreeBuilder

from tests.fixtures.books.fixtures import books
from xsdata.exceptions import XmlHandlerError
from xsdata.formats.dataclass.serializers.tree.mixins import TreeSerializer


class TreeSerializerTests(TestCase):
@mock.patch.object(TreeSerializer, "generate")
def test_build_with_unknown_event(self, mock_generate):
mock_generate.return_value = [
("foobar", True),
]

builder = TreeBuilder()
serializer = TreeSerializer()
with self.assertRaises(XmlHandlerError) as cm:
serializer.build(books, builder)

self.assertEqual("Unhandled event: `foobar`.", str(cm.exception))
39 changes: 39 additions & 0 deletions tests/formats/dataclass/serializers/tree/test_native.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import sys
from unittest import TestCase
from xml.etree import ElementTree

import pytest

from tests.fixtures.books.fixtures import books
from xsdata.formats.dataclass.serializers import XmlTreeSerializer


class XmlTreeSerializerTests(TestCase):
@pytest.mark.skipif(sys.version_info < (3, 9), reason="requires python3.9")
def test_render(self):
serializer = XmlTreeSerializer()
result = serializer.render(books)

ElementTree.indent(result)
actual = ElementTree.tostring(result)
expected = (
'<ns0:books xmlns:ns0="urn:books">\n'
" <book>\n"
" <author>Hightower, Kim</author>\n"
" <title>The First Book</title>\n"
" <genre>Fiction</genre>\n"
" <price>44.95</price>\n"
" <pub_date>2000-10-01</pub_date>\n"
" <review>An amazing story of nothing.</review>\n"
" </book>\n"
" <book>\n"
" <author>Nagata, Suanne</author>\n"
" <title>Becoming Somebody</title>\n"
" <genre>Biography</genre>\n"
" <price>33.95</price>\n"
" <pub_date>2001-01-10</pub_date>\n"
" <review>A masterpiece of the fine art of gossiping.</review>\n"
" </book>\n"
"</ns0:books>"
)
self.assertEqual(expected, actual.decode())
8 changes: 8 additions & 0 deletions xsdata/formats/dataclass/serializers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from xsdata.formats.dataclass.serializers.code import PycodeSerializer
from xsdata.formats.dataclass.serializers.dict import DictEncoder, DictFactory
from xsdata.formats.dataclass.serializers.json import JsonSerializer
from xsdata.formats.dataclass.serializers.tree.native import XmlTreeSerializer
from xsdata.formats.dataclass.serializers.xml import XmlSerializer

__all__ = [
Expand All @@ -10,3 +11,10 @@
"XmlSerializer",
"PycodeSerializer",
]

try:
from xsdata.formats.dataclass.serializers.tree.lxml import LxmlTreeSerializer

__all__.append("LxmlTreeSerializer")
except ImportError:
pass

Check warning on line 20 in xsdata/formats/dataclass/serializers/__init__.py

View check run for this annotation

Codecov / codecov/patch

xsdata/formats/dataclass/serializers/__init__.py#L19-L20

Added lines #L19 - L20 were not covered by tests
Empty file.
22 changes: 22 additions & 0 deletions xsdata/formats/dataclass/serializers/tree/lxml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from dataclasses import dataclass

from lxml.etree import Element, TreeBuilder

from xsdata.formats.bindings import T
from xsdata.formats.dataclass.serializers.tree.mixins import TreeSerializer


@dataclass
class LxmlTreeSerializer(TreeSerializer):
def render(self, obj: T) -> Element:
"""Serialize the input model instance to element tree.
Args:
obj: The input model instance to serialize
Returns:
The generated element tree instance.
"""
builder = TreeBuilder()
self.build(obj, builder)
return builder.close()
59 changes: 59 additions & 0 deletions xsdata/formats/dataclass/serializers/tree/mixins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import abc
from dataclasses import dataclass
from typing import Any, Dict, Protocol

from xsdata.exceptions import XmlHandlerError
from xsdata.formats.bindings import T
from xsdata.formats.dataclass.serializers.mixins import EventGenerator, XmlWriterEvent
from xsdata.models.enums import EventType


class TreeBuilder(Protocol):
def start(self, tag: str, attrs: Dict[str, Any]): ... # pragma: no cover

def end(self, tag: str): ... # pragma: no cover

def data(self, data: str): ... # pragma: no cover


@dataclass
class TreeSerializer(EventGenerator):
@abc.abstractmethod
def render(self, obj: T) -> Any:
"""Serialize the input model instance to element tree.
Args:
obj: The input model instance to serialize
Returns:
The generated element tree instance.
"""

def build(self, obj: T, builder: TreeBuilder):
"""Feed the builder with events from input model instance.
Args:
obj: The input model instance to serialize
builder: The tree builder instance
"""
pending_tag = None
pending_attrs: Dict[str, Any] = {}
for event, *element in self.generate(obj):
if pending_tag is not None:
builder.start(pending_tag, pending_attrs)
pending_tag = None
pending_attrs = {}

if event == XmlWriterEvent.START:
pending_tag = element[0]
pending_attrs = {}
elif event == XmlWriterEvent.ATTR:
key, value = element
pending_attrs[key] = value

elif event == EventType.END:
builder.end(*element)
elif event == XmlWriterEvent.DATA:
builder.data(*element)
else:
raise XmlHandlerError(f"Unhandled event: `{event}`.")
21 changes: 21 additions & 0 deletions xsdata/formats/dataclass/serializers/tree/native.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from dataclasses import dataclass
from xml.etree.ElementTree import Element, TreeBuilder

from xsdata.formats.bindings import T
from xsdata.formats.dataclass.serializers.tree.mixins import TreeSerializer


@dataclass
class XmlTreeSerializer(TreeSerializer):
def render(self, obj: T) -> Element:
"""Serialize the input model instance to element tree.
Args:
obj: The input model instance to serialize
Returns:
The generated element tree instance.
"""
builder = TreeBuilder()
self.build(obj, builder)
return builder.close()

0 comments on commit 6d71a64

Please sign in to comment.