Skip to content

Commit

Permalink
nillable element support added.
Browse files Browse the repository at this point in the history
  • Loading branch information
dapper91 committed Dec 4, 2023
1 parent 7f26d33 commit af59b91
Show file tree
Hide file tree
Showing 12 changed files with 269 additions and 22 deletions.
23 changes: 16 additions & 7 deletions docs/source/pages/misc.rst
Expand Up @@ -42,30 +42,39 @@ The following example illustrate how to encode :py:class:`bytes` typed fields as
:language: xml


None type encoding
__________________
Optional type encoding
~~~~~~~~~~~~~~~~~~~~~~

Since xml format doesn't support ``null`` type natively it is not obvious how to encode ``None`` fields
(ignore it, encode it as an empty string or mark it as ``xsi:nil``). The library encodes ``None`` typed fields
as empty strings by default but you can define your own encoding format:
(ignore it, encode it as an empty string or mark it as ``xsi:nil``).
The library encodes ``None`` values as empty strings by default.
There are some alternative ways:

- Define your own encoding format for ``None`` values:

.. literalinclude:: ../../../examples/snippets/py3.9/serialization.py
:language: python


or drop ``None`` fields at all:
- Mark an empty elements as `nillable <https://www.w3.org/TR/xmlschema-1/#xsi_nil>`_:

.. literalinclude:: ../../../examples/snippets/serialization-nillable.py
:language: python


- Drop empty elements:

.. code-block:: python
from typing import Optional
from pydantic_xml import BaseXmlModel, element
class Company(BaseXmlModel):
class Company(BaseXmlModel, skip_empty=True):
title: Optional[str] = element(default=None)
company = Company()
assert company.to_xml(skip_empty=True) == b'<Company/>'
assert company.to_xml() == b'<Company/>'
Empty entities exclusion
Expand Down
20 changes: 20 additions & 0 deletions examples/snippets/serialization-nillable.py
@@ -0,0 +1,20 @@
from typing import Optional
from xml.etree.ElementTree import canonicalize

from pydantic_xml import BaseXmlModel, element


class Company(BaseXmlModel):
title: Optional[str] = element(default=None, nillable=True)


xml_doc = '''
<Company>
<title xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:nil="true" />
</Company>
'''

company = Company.from_xml(xml_doc)

assert company.title is None
assert canonicalize(company.to_xml(), strip_text=True) == canonicalize(xml_doc, strip_text=True)
1 change: 1 addition & 0 deletions pydantic_xml/element/__init__.py
@@ -1 +1,2 @@
from .element import SearchMode, XmlElement, XmlElementReader, XmlElementWriter
from .utils import is_element_nill, make_element_nill
14 changes: 14 additions & 0 deletions pydantic_xml/element/utils.py
@@ -0,0 +1,14 @@
from .element import XmlElementReader, XmlElementWriter

XSI_NS = 'http://www.w3.org/2001/XMLSchema-instance'


def is_element_nill(element: XmlElementReader) -> bool:
if (is_nil := element.pop_attrib('{%s}nil' % XSI_NS)) and is_nil == 'true':
return True
else:
return False


def make_element_nill(element: XmlElementWriter) -> None:
element.set_attribute('{%s}nil' % XSI_NS, 'true')
24 changes: 19 additions & 5 deletions pydantic_xml/model.py
Expand Up @@ -32,12 +32,13 @@ class ComputedXmlEntityInfo(pd.fields.ComputedFieldInfo):
Computed field xml meta-information.
"""

__slots__ = ('location', 'path', 'ns', 'nsmap', 'wrapped')
__slots__ = ('location', 'path', 'ns', 'nsmap', 'nillable', 'wrapped')

location: Optional[EntityLocation]
path: Optional[str]
ns: Optional[str]
nsmap: Optional[NsMap]
nillable: bool
wrapped: Optional[XmlEntityInfoP] # to be compliant with XmlEntityInfoP protocol

def __post_init__(self) -> None:
Expand All @@ -57,13 +58,15 @@ def decorator(prop: Any) -> Any:
path = kwargs.pop('path', None)
ns = kwargs.pop('ns', None)
nsmap = kwargs.pop('nsmap', None)
nillable = kwargs.pop('nillable', False)

descriptor_proxy = pd.computed_field(**kwargs)(prop)
descriptor_proxy.decorator_info = ComputedXmlEntityInfo(
location=location,
path=path,
ns=ns,
nsmap=nsmap,
nillable=nillable,
wrapped=None,
**dc.asdict(descriptor_proxy.decorator_info),
)
Expand Down Expand Up @@ -101,6 +104,7 @@ def computed_element(
tag: Optional[str] = None,
ns: Optional[str] = None,
nsmap: Optional[NsMap] = None,
nillable: bool = False,
**kwargs: Any,
) -> Union[PropertyT, Callable[[PropertyT], PropertyT]]:
"""
Expand All @@ -110,18 +114,19 @@ def computed_element(
:param tag: element tag
:param ns: element xml namespace
:param nsmap: element xml namespace map
:param nillable: is element nillable. See https://www.w3.org/TR/xmlschema-1/#xsi_nil.
:param kwargs: pydantic computed field arguments. See :py:class:`pydantic.computed_field`
"""

return computed_entity(EntityLocation.ELEMENT, prop, path=tag, ns=ns, nsmap=nsmap, **kwargs)
return computed_entity(EntityLocation.ELEMENT, prop, path=tag, ns=ns, nsmap=nsmap, nillable=nillable, **kwargs)


class XmlEntityInfo(pd.fields.FieldInfo):
"""
Field xml meta-information.
"""

__slots__ = ('location', 'path', 'ns', 'nsmap', 'wrapped')
__slots__ = ('location', 'path', 'ns', 'nsmap', 'nillable', 'wrapped')

def __init__(
self,
Expand All @@ -130,6 +135,7 @@ def __init__(
path: Optional[str] = None,
ns: Optional[str] = None,
nsmap: Optional[NsMap] = None,
nillable: bool = False,
wrapped: Optional[pd.fields.FieldInfo] = None,
**kwargs: Any,
):
Expand All @@ -149,6 +155,7 @@ def __init__(
self.path = path
self.ns = ns
self.nsmap = nsmap
self.nillable = nillable
self.wrapped: Optional[XmlEntityInfoP] = wrapped if isinstance(wrapped, XmlEntityInfo) else None

if config.REGISTER_NS_PREFIXES and nsmap:
Expand All @@ -167,17 +174,24 @@ def attr(name: Optional[str] = None, ns: Optional[str] = None, **kwargs: Any) ->
return XmlEntityInfo(EntityLocation.ATTRIBUTE, path=name, ns=ns, **kwargs)


def element(tag: Optional[str] = None, ns: Optional[str] = None, nsmap: Optional[NsMap] = None, **kwargs: Any) -> Any:
def element(
tag: Optional[str] = None,
ns: Optional[str] = None,
nsmap: Optional[NsMap] = None,
nillable: bool = False,
**kwargs: Any,
) -> Any:
"""
Marks a pydantic field as an xml element.
:param tag: element tag
:param ns: element xml namespace
:param nsmap: element xml namespace map
:param nillable: is element nillable. See https://www.w3.org/TR/xmlschema-1/#xsi_nil.
:param kwargs: pydantic field arguments. See :py:class:`pydantic.Field`
"""

return XmlEntityInfo(EntityLocation.ELEMENT, path=tag, ns=ns, nsmap=nsmap, **kwargs)
return XmlEntityInfo(EntityLocation.ELEMENT, path=tag, ns=ns, nsmap=nsmap, nillable=nillable, **kwargs)


def wrapped(
Expand Down
18 changes: 15 additions & 3 deletions pydantic_xml/serializers/factories/model.py
Expand Up @@ -8,7 +8,7 @@

import pydantic_xml as pxml
from pydantic_xml import errors
from pydantic_xml.element import XmlElementReader, XmlElementWriter
from pydantic_xml.element import XmlElementReader, XmlElementWriter, is_element_nill, make_element_nill
from pydantic_xml.serializers.serializer import SearchMode, Serializer, XmlEntityInfoP
from pydantic_xml.typedefs import EntityLocation, NsMap
from pydantic_xml.utils import QName, merge_nsmaps, select_ns
Expand Down Expand Up @@ -287,8 +287,9 @@ def from_core_schema(cls, schema: pcs.ModelSchema, ctx: Serializer.Context) -> '
nsmap = merge_nsmaps(ctx.entity_nsmap, model_cls.__xml_nsmap__, ctx.parent_nsmap)
search_mode = ctx.search_mode
computed = ctx.field_computed
nillable = ctx.nillable

return cls(model_cls, name, ns, nsmap, search_mode, computed)
return cls(model_cls, name, ns, nsmap, search_mode, computed, nillable)

def __init__(
self,
Expand All @@ -298,12 +299,14 @@ def __init__(
nsmap: Optional[NsMap],
search_mode: SearchMode,
computed: bool,
nillable: bool,
):
self._model = model
self._element_name = QName.from_alias(tag=name, ns=ns, nsmap=nsmap).uri
self._nsmap = nsmap
self._search_mode = search_mode
self._computed = computed
self._nillable = nillable

@property
def model(self) -> Type['pxml.BaseXmlModel']:
Expand Down Expand Up @@ -331,6 +334,12 @@ def serialize(
) -> Optional[XmlElementWriter]:
assert self._model.__xml_serializer__ is not None, f"model {self._model.__name__} is partially initialized"

if self._nillable and value is None:
sub_element = element.make_element(self._element_name, nsmap=self._nsmap)
make_element_nill(sub_element)
element.append_element(sub_element)
return sub_element

if value is None:
return None

Expand All @@ -355,7 +364,10 @@ def deserialize(

if element is not None and \
(sub_element := element.pop_element(self._element_name, self._search_mode)) is not None:
return self._model.__xml_serializer__.deserialize(sub_element, context=context)
if is_element_nill(sub_element):
return None
else:
return self._model.__xml_serializer__.deserialize(sub_element, context=context)
else:
return None

Expand Down
29 changes: 23 additions & 6 deletions pydantic_xml/serializers/factories/primitive.py
Expand Up @@ -3,7 +3,7 @@
from pydantic_core import core_schema as pcs

from pydantic_xml import errors
from pydantic_xml.element import XmlElementReader, XmlElementWriter
from pydantic_xml.element import XmlElementReader, XmlElementWriter, is_element_nill, make_element_nill
from pydantic_xml.serializers.serializer import SearchMode, Serializer, encode_primitive
from pydantic_xml.typedefs import EntityLocation, NsMap
from pydantic_xml.utils import QName, merge_nsmaps, select_ns
Expand Down Expand Up @@ -32,18 +32,23 @@ class TextSerializer(Serializer):
@classmethod
def from_core_schema(cls, schema: PrimitiveTypeSchema, ctx: Serializer.Context) -> 'TextSerializer':
computed = ctx.field_computed
nillable = ctx.nillable

return cls(computed)
return cls(computed, nillable)

def __init__(self, computed: bool):
def __init__(self, computed: bool, nillable: bool):
self._computed = computed
self._nillable = nillable

def serialize(
self, element: XmlElementWriter, value: Any, encoded: Any, *, skip_empty: bool = False,
) -> Optional[XmlElementWriter]:
if value is None and skip_empty:
return element

if self._nillable and value is None:
make_element_nill(element)

element.set_text(encode_primitive(encoded))
return element

Expand All @@ -59,6 +64,9 @@ def deserialize(
if element is None:
return None

if self._nillable and is_element_nill(element):
return None

return element.pop_text() or None


Expand Down Expand Up @@ -123,14 +131,23 @@ def from_core_schema(cls, schema: PrimitiveTypeSchema, ctx: Serializer.Context)
nsmap = merge_nsmaps(ctx.entity_nsmap, ctx.parent_nsmap)
search_mode = ctx.search_mode
computed = ctx.field_computed
nillable = ctx.nillable

if name is None:
raise errors.ModelFieldError(ctx.model_name, ctx.field_name, "entity name is not provided")

return cls(name, ns, nsmap, search_mode, computed)
return cls(name, ns, nsmap, search_mode, computed, nillable)

def __init__(self, name: str, ns: Optional[str], nsmap: Optional[NsMap], search_mode: SearchMode, computed: bool):
super().__init__(computed)
def __init__(
self,
name: str,
ns: Optional[str],
nsmap: Optional[NsMap],
search_mode: SearchMode,
computed: bool,
nillable: bool,
):
super().__init__(computed, nillable)

self._nsmap = nsmap
self._search_mode = search_mode
Expand Down
5 changes: 5 additions & 0 deletions pydantic_xml/serializers/serializer.py
Expand Up @@ -96,6 +96,7 @@ class XmlEntityInfoP(typing.Protocol):
path: Optional[str]
ns: Optional[str]
nsmap: Optional[NsMap]
nillable: bool
wrapped: Optional['XmlEntityInfoP']


Expand Down Expand Up @@ -137,6 +138,10 @@ def entity_ns(self) -> Optional[str]:
def entity_nsmap(self) -> Optional[NsMap]:
return self.entity_info.nsmap if self.entity_info is not None else None

@property
def nillable(self) -> bool:
return self.entity_info.nillable if self.entity_info is not None else False

@property
def entity_wrapped(self) -> Optional['XmlEntityInfoP']:
return self.entity_info.wrapped if self.entity_info is not None else None
Expand Down

0 comments on commit af59b91

Please sign in to comment.