Skip to content

Commit c0b64a8

Browse files
authored
Merge pull request #112 from dapper91/dev
- model level skip_empty parameter added. - wrapped element extra entities checking bugs fixed.
2 parents 3611946 + 2386efd commit c0b64a8

File tree

9 files changed

+373
-111
lines changed

9 files changed

+373
-111
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.2.1 (2023-09-12)
5+
------------------
6+
7+
- model level `skip_empty` parameter added.
8+
- wrapped element extra entities checking bugs fixed.
9+
10+
411
2.2.0 (2023-09-07)
512
------------------
613

docs/source/pages/misc.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,25 @@ or drop ``None`` fields at all:
7070
assert company.to_xml(skip_empty=True) == b'<Company/>'
7171
7272
73+
Empty entities exclusion
74+
~~~~~~~~~~~~~~~~~~~~~~~~
75+
76+
It is possible to exclude all empty entities from the resulting xml document at once. To do that
77+
just pass ``skip_empty=True`` parameter to :py:meth:`pydantic_xml.BaseXmlModel.to_xml` during serialization.
78+
That parameter is applied to the root model and all its sub-models by default. But it can be adjusted
79+
for a particular model during its declaration as illustrated in the following example:
80+
81+
.. literalinclude:: ../../../examples/snippets/skip_empty.py
82+
:language: python
83+
:start-after: model-start
84+
:end-before: model-end
85+
86+
.. literalinclude:: ../../../examples/snippets/skip_empty.py
87+
:language: xml
88+
:lines: 2-
89+
:start-after: xml-start
90+
:end-before: xml-end
91+
7392

7493
Default namespace
7594
~~~~~~~~~~~~~~~~~

examples/snippets/skip_empty.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from typing import Literal, Optional, Tuple
2+
from xml.etree.ElementTree import canonicalize
3+
4+
from pydantic_xml import BaseXmlModel, attr, element
5+
6+
7+
# [model-start]
8+
class Product(BaseXmlModel, tag='Product', skip_empty=True):
9+
status: Optional[Literal['running', 'development']] = attr(default=None)
10+
launched: Optional[int] = attr(default=None)
11+
title: Optional[str] = element(tag='Title', default=None)
12+
13+
14+
class Company(BaseXmlModel, tag='Company'):
15+
trade_name: str = attr(name='trade-name')
16+
website: str = element(tag='WebSite', default='')
17+
18+
products: Tuple[Product, ...] = element()
19+
20+
21+
company = Company(
22+
trade_name="SpaceX",
23+
products=[
24+
Product(status="running", launched=2013, title="Several launch vehicles"),
25+
Product(status="running", title="Starlink"),
26+
Product(status="development"),
27+
Product(),
28+
],
29+
)
30+
# [model-end]
31+
32+
33+
# [xml-start]
34+
xml_doc = '''
35+
<Company trade-name="SpaceX">
36+
<WebSite /><!--Company empty elements are not excluded-->
37+
38+
<!--Product empty sub-elements and attributes are excluded-->
39+
<Product status="running" launched="2013">
40+
<Title>Several launch vehicles</Title>
41+
</Product>
42+
<Product status="running">
43+
<Title>Starlink</Title>
44+
</Product>
45+
<Product status="development"/>
46+
<Product />
47+
</Company>
48+
''' # [xml-end]
49+
50+
assert canonicalize(company.to_xml(), strip_text=True) == canonicalize(xml_doc, strip_text=True)

pydantic_xml/element/element.py

Lines changed: 24 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import abc
22
from enum import Enum
3-
from typing import Any, Callable, Dict, Generic, List, Optional, Sequence, TypeVar
3+
from typing import Any, Callable, Dict, Generic, List, Optional, Sequence, Tuple, TypeVar
44

55
from pydantic_xml.typedefs import NsMap
66

@@ -52,14 +52,6 @@ def find_element(
5252
:return: xml element
5353
"""
5454

55-
@abc.abstractmethod
56-
def get_text(self) -> Optional[str]:
57-
"""
58-
Returns the element text.
59-
60-
:return: element text
61-
"""
62-
6355
@abc.abstractmethod
6456
def pop_text(self) -> Optional[str]:
6557
"""
@@ -78,14 +70,6 @@ def pop_attrib(self, name: str) -> Optional[str]:
7870
:return: element attribute
7971
"""
8072

81-
@abc.abstractmethod
82-
def get_attributes(self) -> Optional[Dict[str, str]]:
83-
"""
84-
Returns the element attributes.
85-
86-
:return: element attributes
87-
"""
88-
8973
@abc.abstractmethod
9074
def pop_attributes(self) -> Optional[Dict[str, str]]:
9175
"""
@@ -115,14 +99,6 @@ def find_sub_element(self, path: Sequence[str], search_mode: 'SearchMode') -> Op
11599
:return: found element or `None`
116100
"""
117101

118-
@abc.abstractmethod
119-
def get_elements(self) -> Optional[List['XmlElement[Any]']]:
120-
"""
121-
Returns the element sub-elements.
122-
123-
:return: sub-element
124-
"""
125-
126102
@abc.abstractmethod
127103
def create_snapshot(self) -> 'XmlElement[Any]':
128104
"""
@@ -145,6 +121,14 @@ def to_native(self) -> Any:
145121
:return: native element
146122
"""
147123

124+
@abc.abstractmethod
125+
def get_unbound(self) -> List[Tuple[Tuple[str, ...], str]]:
126+
"""
127+
Returns unbound entities.
128+
129+
:return: list of unbound entities
130+
"""
131+
148132

149133
class XmlElementWriter(abc.ABC):
150134
"""
@@ -251,7 +235,7 @@ def __init__(
251235
self.elements = elements
252236
self.next_element_idx = next_element_idx
253237

254-
__slots__ = ('_tag', '_nsmap')
238+
__slots__ = ('_tag', '_nsmap', '_state')
255239

256240
@classmethod
257241
@abc.abstractmethod
@@ -337,9 +321,6 @@ def append_element(self, element: 'XmlElement[NativeElement]') -> None:
337321
def get_attrib(self, name: str) -> Optional[str]:
338322
return self._state.attrib.get(name, None) if self._state.attrib else None
339323

340-
def get_text(self) -> Optional[str]:
341-
return self._state.text
342-
343324
def pop_text(self) -> Optional[str]:
344325
result, self._state.text = self._state.text, None
345326

@@ -348,9 +329,6 @@ def pop_text(self) -> Optional[str]:
348329
def pop_attrib(self, name: str) -> Optional[str]:
349330
return self._state.attrib.pop(name, None) if self._state.attrib else None
350331

351-
def get_attributes(self) -> Optional[Dict[str, str]]:
352-
return self._state.attrib
353-
354332
def pop_attributes(self) -> Optional[Dict[str, str]]:
355333
result, self._state.attrib = self._state.attrib, None
356334

@@ -395,8 +373,20 @@ def find_element(
395373

396374
return searcher(self._state, tag, look_behind, step_forward)
397375

398-
def get_elements(self) -> Optional[List['XmlElement[NativeElement]']]:
399-
return self._state.elements[self._state.next_element_idx:]
376+
def get_unbound(self, path: Tuple[str, ...] = ()) -> List[Tuple[Tuple[str, ...], str]]:
377+
result: List[Tuple[Tuple[str, ...], str]] = []
378+
379+
if self._state.text and (text := self._state.text.strip()):
380+
result.append((path, text))
381+
382+
if attrs := self._state.attrib:
383+
for name, value in attrs.items():
384+
result.append((path + (f'@{name}',), value))
385+
386+
for sub_element in self._state.elements:
387+
result.extend(sub_element.get_unbound(path + (sub_element.tag,)))
388+
389+
return result
400390

401391

402392
class SearchMode(str, Enum):

pydantic_xml/model.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ class BaseXmlModel(BaseModel, __xml_abstract__=True, metaclass=XmlModelMeta):
233233
__xml_ns__: ClassVar[Optional[str]]
234234
__xml_nsmap__: ClassVar[Optional[NsMap]]
235235
__xml_ns_attrs__: ClassVar[bool]
236+
__xml_skip_empty__: ClassVar[Optional[bool]]
236237
__xml_search_mode__: ClassVar[SearchMode]
237238
__xml_serializer__: ClassVar[Optional[BaseModelSerializer]] = None
238239

@@ -242,6 +243,7 @@ def __init_subclass__(
242243
ns: Optional[str] = None,
243244
nsmap: Optional[NsMap] = None,
244245
ns_attrs: Optional[bool] = None,
246+
skip_empty: Optional[bool] = None,
245247
search_mode: Optional[SearchMode] = None,
246248
**kwargs: Any,
247249
):
@@ -261,6 +263,7 @@ def __init_subclass__(
261263
cls.__xml_ns__ = ns if ns is not None else getattr(cls, '__xml_ns__', None)
262264
cls.__xml_nsmap__ = nsmap if nsmap is not None else getattr(cls, '__xml_nsmap__', None)
263265
cls.__xml_ns_attrs__ = ns_attrs if ns_attrs is not None else getattr(cls, '__xml_ns_attrs__', False)
266+
cls.__xml_skip_empty__ = skip_empty if skip_empty is not None else getattr(cls, '__xml_skip_empty__', None)
264267
cls.__xml_search_mode__ = search_mode if search_mode is not None \
265268
else getattr(cls, '__xml_search_mode__', SearchMode.STRICT)
266269

pydantic_xml/serializers/factories/model.py

Lines changed: 14 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -31,33 +31,14 @@ def nsmap(self) -> Optional[NsMap]: ...
3131
def _check_extra(cls, error_title: str, element: XmlElementReader) -> None:
3232
line_errors: List[pdc.InitErrorDetails] = []
3333

34-
if (text := element.get_text()) is not None:
35-
if text := text.strip():
36-
line_errors.append(
37-
pdc.InitErrorDetails(
38-
type='extra_forbidden',
39-
loc=('<text>',),
40-
input=text,
41-
),
42-
)
43-
if extra_attrs := element.get_attributes():
44-
for name, value in extra_attrs.items():
45-
line_errors.append(
46-
pdc.InitErrorDetails(
47-
type='extra_forbidden',
48-
loc=(f'<attr> {name}',),
49-
input=value,
50-
),
51-
)
52-
if extra_elements := element.get_elements():
53-
for extra_element in extra_elements:
54-
line_errors.append(
55-
pdc.InitErrorDetails(
56-
type='extra_forbidden',
57-
loc=(f'<element> {extra_element.tag}',),
58-
input=extra_element.get_text(),
59-
),
60-
)
34+
for path, value in element.get_unbound():
35+
line_errors.append(
36+
pdc.InitErrorDetails(
37+
type='extra_forbidden',
38+
loc=path,
39+
input=value,
40+
),
41+
)
6142

6243
if line_errors:
6344
raise pd.ValidationError.from_exception_data(title=error_title, line_errors=line_errors)
@@ -171,6 +152,9 @@ def serialize(
171152
if value is None:
172153
return None
173154

155+
if self._model.__xml_skip_empty__ is not None:
156+
skip_empty = self._model.__xml_skip_empty__
157+
174158
for field_name, field_serializer in self._field_serializers.items():
175159
if field_name not in self._fields_serialization_exclude:
176160
field_serializer.serialize(
@@ -264,6 +248,9 @@ def serialize(
264248
if value is None:
265249
return None
266250

251+
if self._model.__xml_skip_empty__ is not None:
252+
skip_empty = self._model.__xml_skip_empty__
253+
267254
self._root_serializer.serialize(element, getattr(value, 'root'), encoded, skip_empty=skip_empty)
268255

269256
return element

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "pydantic-xml"
3-
version = "2.2.0"
3+
version = "2.2.1"
44
description = "pydantic xml extension"
55
authors = ["Dmitry Pershin <dapper1291@gmail.com>"]
66
license = "Unlicense"

0 commit comments

Comments
 (0)