Skip to content

Commit 5e15e1a

Browse files
authored
Merge pull request #104 from dapper91/dev
- raw element typed fields support added. See #14. - pydantic field exclude flag bug fixed (works only for serialization now).
2 parents 7cac05f + 7a050ca commit 5e15e1a

File tree

22 files changed

+458
-41
lines changed

22 files changed

+458
-41
lines changed

CHANGELOG.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
Changelog
22
=========
33

4+
2.1.0 (2023-08-24)
5+
------------------
6+
7+
- raw element typed fields support added. See https://github.com/dapper91/pydantic-xml/issues/14.
8+
- pydantic field exclude flag bug fixed (works only for serialization now).
9+
10+
411
2.0.0 (2023-08-19)
512
------------------
613

README.rst

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ ______________________
4444

4545
- `dynamic model creation <https://docs.pydantic.dev/usage/models/#dynamic-model-creation>`_
4646
- `dataclasses <https://docs.pydantic.dev/usage/dataclasses/>`_
47-
- `discriminated unions <https://docs.pydantic.dev/usage/types/#discriminated-unions-aka-tagged-unions>`_
4847

4948
Getting started
5049
---------------

docs/source/pages/data-binding/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ Data binding
1717
generics
1818
wrapper
1919
aliases
20+
raw
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
.. _raw_fields:
2+
3+
4+
Raw fields
5+
__________
6+
7+
Raw element typed fields
8+
************************
9+
10+
The library supports raw xml elements. It is helpful when the element schema is unknown or its schema is too complex
11+
to define a model describing it.
12+
13+
To declare a raw element field annotate it with :py:class:`xml.etree.ElementTree.Element`
14+
(or :py:class:`lxml.etree._Element` for ``lxml``).
15+
16+
Since ``pydantic`` doesn't support arbitrary types by default it is necessary to allow them
17+
by setting ``arbitrary_types_allowed`` attribute.
18+
See `documentation <https://docs.pydantic.dev/latest/usage/model_config/#arbitrary-types-allowed>`_ for more details.
19+
20+
21+
.. grid:: 2
22+
:gutter: 2
23+
24+
.. grid-item-card:: Model
25+
26+
.. literalinclude:: ../../../../examples/snippets/element_raw.py
27+
:language: python
28+
:start-after: model-start
29+
:end-before: model-end
30+
31+
.. grid-item-card:: Document
32+
33+
.. tab-set::
34+
35+
.. tab-item:: input XML
36+
37+
.. literalinclude:: ../../../../examples/snippets/element_raw.py
38+
:language: xml
39+
:lines: 2-
40+
:start-after: xml-start-1
41+
:end-before: xml-end-1
42+
43+
.. tab-item:: output XML
44+
45+
.. literalinclude:: ../../../../examples/snippets/element_raw.py
46+
:language: xml
47+
:lines: 2-
48+
:start-after: xml-start-2
49+
:end-before: xml-end-2

examples/snippets/element_raw.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from typing import List
2+
from xml.etree.ElementTree import canonicalize
3+
4+
from pydantic import HttpUrl
5+
6+
from pydantic_xml import BaseXmlModel, computed_element, element
7+
from pydantic_xml.element.native import ElementT as Element
8+
9+
10+
# [model-start]
11+
class Contact(BaseXmlModel, tag='contact'):
12+
url: HttpUrl
13+
14+
15+
class Contacts(
16+
BaseXmlModel,
17+
tag='contacts',
18+
arbitrary_types_allowed=True,
19+
):
20+
contacts_raw: List[Element] = element(tag='contact', exclude=True)
21+
22+
@computed_element
23+
def parse_raw_contacts(self) -> List[Contact]:
24+
contacts: List[Contact] = []
25+
for contact_raw in self.contacts_raw:
26+
if url := contact_raw.attrib.get('url'):
27+
contact = Contact(url=url)
28+
elif (link := contact_raw.find('link')) is not None:
29+
contact = Contact(url=link.text)
30+
else:
31+
contact = Contact(url=contact_raw.text.strip())
32+
33+
contacts.append(contact)
34+
35+
return contacts
36+
# [model-end]
37+
38+
39+
# [xml-start-1]
40+
src_doc = '''
41+
<contacts>
42+
<contact url="https://www.linkedin.com/company/spacex" />
43+
<contact>
44+
<link>https://twitter.com/spacex</link>
45+
</contact>
46+
<contact>https://www.youtube.com/spacex</contact>
47+
</contacts>
48+
''' # [xml-end-1]
49+
50+
51+
contacts = Contacts.from_xml(src_doc)
52+
53+
54+
# [xml-start-2]
55+
dst_doc = '''
56+
<contacts>
57+
<contact>https://www.linkedin.com/company/spacex</contact>
58+
<contact>https://twitter.com/spacex</contact>
59+
<contact>https://www.youtube.com/spacex</contact>
60+
</contacts>
61+
''' # [xml-end-2]
62+
63+
assert canonicalize(contacts.to_xml(), strip_text=True) == canonicalize(dst_doc, strip_text=True)

pydantic_xml/element/element.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def find_element(
3434
search_mode: 'SearchMode',
3535
look_behind: bool = True,
3636
step_forward: bool = True,
37-
) -> Optional['XmlElement[Any]']:
37+
) -> Optional['XmlElementReader']:
3838
"""
3939
Searches for an element with the provided tag.
4040
@@ -73,7 +73,7 @@ def pop_attributes(self) -> Optional[Dict[str, str]]:
7373
"""
7474

7575
@abc.abstractmethod
76-
def pop_element(self, tag: str, search_mode: 'SearchMode') -> Optional['XmlElement[Any]']:
76+
def pop_element(self, tag: str, search_mode: 'SearchMode') -> Optional['XmlElementReader']:
7777
"""
7878
Extracts a sub-element from the xml element matching `tag`.
7979
@@ -83,7 +83,7 @@ def pop_element(self, tag: str, search_mode: 'SearchMode') -> Optional['XmlEleme
8383
"""
8484

8585
@abc.abstractmethod
86-
def find_sub_element(self, path: Sequence[str], search_mode: 'SearchMode') -> Optional['XmlElement[Any]']:
86+
def find_sub_element(self, path: Sequence[str], search_mode: 'SearchMode') -> Optional['XmlElementReader']:
8787
"""
8888
Searches for an element at the provided path. If the element is not found returns `None`.
8989
@@ -106,6 +106,14 @@ def apply_snapshot(self, snapshot: 'XmlElement[Any]') -> None:
106106
Applies a snapshot to the current element.
107107
"""
108108

109+
@abc.abstractmethod
110+
def to_native(self) -> Any:
111+
"""
112+
Transforms current element to a native one.
113+
114+
:return: native element
115+
"""
116+
109117

110118
class XmlElementWriter(abc.ABC):
111119
"""
@@ -165,7 +173,7 @@ def make_element(self, tag: str, nsmap: Optional[NsMap]) -> 'XmlElement[Any]':
165173
"""
166174

167175
@abc.abstractmethod
168-
def find_element_or_create(self, tag: str, search_mode: 'SearchMode', nsmap: Optional[NsMap]) -> 'XmlElement[Any]':
176+
def find_element_or_create(self, tag: str, search_mode: 'SearchMode', nsmap: Optional[NsMap]) -> 'XmlElementWriter':
169177
"""
170178
Searches for an element with the provided tag.
171179
If the element is found returns it otherwise creates a new one.
@@ -176,6 +184,16 @@ def find_element_or_create(self, tag: str, search_mode: 'SearchMode', nsmap: Opt
176184
:return: xml element
177185
"""
178186

187+
@classmethod
188+
@abc.abstractmethod
189+
def from_native(cls, element: Any) -> 'XmlElement[Any]':
190+
"""
191+
Creates a instance of `XmlElement` from native element.
192+
193+
:param element: native element
194+
:return: `XmlElement`
195+
"""
196+
179197

180198
NativeElement = TypeVar('NativeElement')
181199

pydantic_xml/element/native/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pydantic_xml.element import XmlElement as BaseXmlElement
55

66
XmlElement: Type[BaseXmlElement[Any]]
7+
ElementT: Type[Any]
78

89
if config.FORCE_STD_XML:
910
from .std import * # noqa: F403

pydantic_xml/element/native/lxml.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,17 @@
66
from pydantic_xml.typedefs import NsMap
77

88
__all__ = (
9+
'ElementT',
910
'XmlElement',
1011
'etree',
1112
)
1213

14+
ElementT = etree._Element
1315

14-
class XmlElement(BaseXmlElement[etree._Element]):
16+
17+
class XmlElement(BaseXmlElement[ElementT]):
1518
@classmethod
16-
def from_native(cls, element: etree._Element) -> 'XmlElement':
19+
def from_native(cls, element: ElementT) -> 'XmlElement':
1720
return cls(
1821
tag=element.tag,
1922
text=element.text,
@@ -28,7 +31,7 @@ def from_native(cls, element: etree._Element) -> 'XmlElement':
2831
],
2932
)
3033

31-
def to_native(self) -> etree._Element:
34+
def to_native(self) -> ElementT:
3235
element = etree.Element(
3336
self._tag,
3437
attrib=self._state.attrib,
@@ -51,5 +54,5 @@ def force_str(val: Union[str, bytes]) -> str:
5154
return val
5255

5356

54-
def is_xml_comment(element: etree._Element) -> bool:
57+
def is_xml_comment(element: ElementT) -> bool:
5558
return isinstance(element, etree._Comment)

pydantic_xml/element/native/std.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@
55
from pydantic_xml.typedefs import NsMap
66

77
__all__ = (
8+
'ElementT',
89
'XmlElement',
910
'etree',
1011
)
1112

13+
ElementT = etree.Element
1214

13-
class XmlElement(BaseXmlElement[etree.Element]):
15+
16+
class XmlElement(BaseXmlElement[ElementT]):
1417
@classmethod
15-
def from_native(cls, element: etree.Element) -> 'XmlElement':
18+
def from_native(cls, element: ElementT) -> 'XmlElement':
1619
return cls(
1720
tag=element.tag,
1821
text=element.text,
@@ -24,7 +27,7 @@ def from_native(cls, element: etree.Element) -> 'XmlElement':
2427
],
2528
)
2629

27-
def to_native(self) -> etree.Element:
30+
def to_native(self) -> ElementT:
2831
element = etree.Element(self._tag, attrib=self._state.attrib or {})
2932
element.text = self._state.text
3033
element.extend([element.to_native() for element in self._state.elements])
@@ -35,5 +38,5 @@ def make_element(self, tag: str, nsmap: Optional[NsMap]) -> 'XmlElement':
3538
return XmlElement(tag)
3639

3740

38-
def is_xml_comment(element: etree.Element) -> bool:
41+
def is_xml_comment(element: ElementT) -> bool:
3942
return element.tag is etree.Comment # type: ignore[comparison-overlap]

pydantic_xml/model.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from . import config, errors, utils
1111
from .element import SearchMode
12-
from .element.native import XmlElement, etree
12+
from .element.native import ElementT, XmlElement, etree
1313
from .serializers.factories.model import BaseModelSerializer
1414
from .serializers.serializer import Serializer, XmlEntityInfoP
1515
from .typedefs import EntityLocation
@@ -354,7 +354,11 @@ def to_xml_tree(self, *, skip_empty: bool = False) -> etree.Element:
354354

355355
root = XmlElement(tag=self.__xml_serializer__.element_name, nsmap=self.__xml_serializer__.nsmap)
356356
self.__xml_serializer__.serialize(
357-
root, self, pdc.to_jsonable_python(self, by_alias=False), skip_empty=skip_empty,
357+
root, self, pdc.to_jsonable_python(
358+
self,
359+
by_alias=False,
360+
fallback=lambda obj: obj if not isinstance(obj, ElementT) else None, # for raw fields support
361+
), skip_empty=skip_empty,
358362
)
359363

360364
return root.to_native()

0 commit comments

Comments
 (0)