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

Dev #213

Merged
merged 4 commits into from
Sep 29, 2024
Merged

Dev #213

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
Changelog
=========

2.13.0 (2024-09-29)
-------------------

- custom field xml serializer/validator support added. See https://github.com/dapper91/pydantic-xml/pull/212



2.12.1 (2024-08-26)
-------------------

Expand Down
20 changes: 20 additions & 0 deletions docs/source/pages/misc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,26 @@ during the xml serialization:
:language: xml


Custom xml serialization
________________________

``pydantic-xml`` provides functional serializers and validators to customise how a field is serialized to xml
or validated from it. Use :py:func:`pydantic_xml.xml_field_serializer` decorator to mark a method as an xml serializer
or :py:func:`pydantic_xml.xml_field_serializer` decorators to mark it as an xml validator.

The following example illustrate how to serialize ``xs:list`` element:

*model.py:*

.. literalinclude:: ../../../examples/xml-serialization/model.py
:language: python

*doc.xml:*

.. literalinclude:: ../../../examples/xml-serialization/doc.xml
:language: xml


Optional type encoding
~~~~~~~~~~~~~~~~~~~~~~

Expand Down
18 changes: 7 additions & 11 deletions examples/computed-entities/model.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import pathlib
from ipaddress import IPv4Address
from typing import Dict, List, Tuple
from typing import Dict, List
from xml.etree.ElementTree import canonicalize

from pydantic import Field, IPvAnyAddress, SecretStr, computed_field, field_validator
from pydantic import Field, IPvAnyAddress, SecretStr, computed_field

from pydantic_xml import BaseXmlModel, attr, computed_attr, computed_element

Expand All @@ -14,22 +14,18 @@ class Auth(BaseXmlModel, tag='Authorization'):


class Request(BaseXmlModel, tag='Request'):
forwarded_for: List[IPv4Address] = Field(exclude=True)
raw_forwarded_for: str = Field(exclude=True)
raw_cookies: str = Field(exclude=True)
raw_auth: str = Field(exclude=True)

@field_validator('forwarded_for', mode='before')
def validate_address_list(cls, value: str) -> List[IPv4Address]:
return [IPvAnyAddress(addr) for addr in value.split(',')]

@computed_attr(name='Client')
def client(self) -> IPv4Address:
client, *proxies = self.forwarded_for
client, *proxies = [IPvAnyAddress(addr) for addr in self.raw_forwarded_for.split(',')]
return client

@computed_element(tag='Proxy')
def proxy(self) -> Tuple[IPv4Address]:
client, *proxies = self.forwarded_for
def proxy(self) -> List[IPv4Address]:
client, *proxies = [IPvAnyAddress(addr) for addr in self.raw_forwarded_for.split(',')]
return proxies

@computed_element(tag='Cookies')
Expand All @@ -47,7 +43,7 @@ def auth(self) -> Auth:


request = Request(
forwarded_for="203.0.113.195,150.172.238.178,150.172.230.21",
raw_forwarded_for="203.0.113.195,150.172.238.178,150.172.230.21",
raw_cookies="PHPSESSID=298zf09hf012fh2; csrftoken=u32t4o3tb3gg43;",
raw_auth="Basic YWxhZGRpbjpvcGVuc2VzYW1l",
)
Expand Down
4 changes: 4 additions & 0 deletions examples/xml-serialization/doc.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<Plot>
<x>0.0 1.0 2.0 3.0 4.0 5.0</x>
<y>0.0 3.2 5.4 4.1 2.0 -1.2</y>
</Plot>
31 changes: 31 additions & 0 deletions examples/xml-serialization/model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import pathlib
from typing import List
from xml.etree.ElementTree import canonicalize

from pydantic_xml import BaseXmlModel, element, xml_field_serializer, xml_field_validator
from pydantic_xml.element import XmlElementReader, XmlElementWriter


class Plot(BaseXmlModel):
x: List[float] = element()
y: List[float] = element()

@xml_field_validator('x', 'y')
def validate_space_separated_list(cls, element: XmlElementReader, field_name: str) -> List[float]:
if element := element.pop_element(field_name, search_mode=cls.__xml_search_mode__):
return list(map(float, element.pop_text().split()))

return []

@xml_field_serializer('x', 'y')
def serialize_space_separated_list(self, element: XmlElementWriter, value: List[float], field_name: str) -> None:
sub_element = element.make_element(tag=field_name, nsmap=None)
sub_element.set_text(' '.join(map(str, value)))

element.append_element(sub_element)


xml_doc = pathlib.Path('./doc.xml').read_text()
plot = Plot.from_xml(xml_doc)

assert canonicalize(plot.to_xml(), strip_text=True) == canonicalize(xml_doc, strip_text=True)
3 changes: 3 additions & 0 deletions pydantic_xml/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from . import config, errors, model
from .errors import ModelError, ParsingError
from .model import BaseXmlModel, RootXmlModel, attr, computed_attr, computed_element, create_model, element, wrapped
from .model import xml_field_serializer, xml_field_validator

__all__ = (
'BaseXmlModel',
Expand All @@ -19,4 +20,6 @@
'create_model',
'errors',
'model',
'xml_field_serializer',
'xml_field_validator',
)
56 changes: 55 additions & 1 deletion pydantic_xml/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from pydantic.root_model import _RootModelMetaclass as RootModelMetaclass # noqa

from . import config, errors, utils
from .element import SearchMode
from .element import SearchMode, XmlElementReader, XmlElementWriter
from .element.native import ElementT, XmlElement, etree
from .serializers.factories.model import BaseModelSerializer
from .serializers.serializer import Serializer, XmlEntityInfoP
Expand All @@ -24,6 +24,8 @@
'wrapped',
'computed_attr',
'computed_element',
'xml_field_serializer',
'xml_field_validator',
'BaseXmlModel',
'RootXmlModel',
)
Expand Down Expand Up @@ -315,6 +317,44 @@ def create_model(
return typing.cast(Type[Model], model)


ValidatorFunc = Callable[[Type['BaseXmlModel'], XmlElementReader, str], Any]
ValidatorFuncT = TypeVar('ValidatorFuncT', bound=ValidatorFunc)


def xml_field_validator(field: str, /, *fields: str) -> Callable[[ValidatorFuncT], ValidatorFuncT]:
"""
Marks the method as a field xml validator.

:param field: field to be validated
:param fields: fields to be validated
"""

def wrapper(func: ValidatorFuncT) -> ValidatorFuncT:
setattr(func, '__xml_field_validator__', (field, *fields))
return func

return wrapper


SerializerFunc = Callable[['BaseXmlModel', XmlElementWriter, Any, str], Any]
SerializerFuncT = TypeVar('SerializerFuncT', bound=SerializerFunc)


def xml_field_serializer(field: str, /, *fields: str) -> Callable[[SerializerFuncT], SerializerFuncT]:
"""
Marks the method as a field xml serializer.

:param field: field to be serialized
:param fields: fields to be serialized
"""

def wrapper(func: SerializerFuncT) -> SerializerFuncT:
setattr(func, '__xml_field_serializer__', (field, *fields))
return func

return wrapper


@te.dataclass_transform(kw_only_default=True, field_specifiers=(attr, element, wrapped, pd.Field))
class XmlModelMeta(ModelMetaclass):
"""
Expand Down Expand Up @@ -353,6 +393,9 @@ class BaseXmlModel(BaseModel, __xml_abstract__=True, metaclass=XmlModelMeta):
__xml_search_mode__: ClassVar[SearchMode]
__xml_serializer__: ClassVar[Optional[BaseModelSerializer]] = None

__xml_field_validators__: ClassVar[Dict[str, ValidatorFunc]] = {}
__xml_field_serializers__: ClassVar[Dict[str, SerializerFunc]] = {}

def __init_subclass__(
cls,
tag: Optional[str] = None,
Expand Down Expand Up @@ -384,6 +427,17 @@ def __init_subclass__(
cls.__xml_search_mode__ = search_mode if search_mode is not None \
else getattr(cls, '__xml_search_mode__', SearchMode.STRICT)

cls.__xml_field_serializers__ = {}
cls.__xml_field_validators__ = {}
for attr_name in dir(cls):
if func := getattr(cls, attr_name, None):
if fields := getattr(func, '__xml_field_serializer__', None):
for field in fields:
cls.__xml_field_serializers__[field] = func
if fields := getattr(func, '__xml_field_validator__', None):
for field in fields:
cls.__xml_field_validators__[field] = func

@classmethod
def __build_serializer__(cls) -> None:
if cls is BaseXmlModel:
Expand Down
21 changes: 14 additions & 7 deletions pydantic_xml/serializers/factories/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,12 +173,15 @@ def serialize(
if exclude_unset and field_name not in value.__pydantic_fields_set__:
continue

field_serializer.serialize(
element, getattr(value, field_name), encoded[field_name],
skip_empty=skip_empty,
exclude_none=exclude_none,
exclude_unset=exclude_unset,
)
if custom_field_serializer := self._model.__xml_field_serializers__.get(field_name):
custom_field_serializer(value, element, getattr(value, field_name), field_name)
else:
field_serializer.serialize(
element, getattr(value, field_name), encoded[field_name],
skip_empty=skip_empty,
exclude_none=exclude_none,
exclude_unset=exclude_unset,
)

return element

Expand All @@ -199,7 +202,11 @@ def deserialize(
try:
loc = (field_name,)
sourcemap[loc] = element.get_sourceline()
field_value = field_serializer.deserialize(element, context=context, sourcemap=sourcemap, loc=loc)
if custom_field_validator := self._model.__xml_field_validators__.get(field_name):
field_value = custom_field_validator(self._model, element, field_name)
else:
field_value = field_serializer.deserialize(element, context=context, sourcemap=sourcemap, loc=loc)

if field_value is not None:
field_name = self._fields_validation_aliases.get(field_name, field_name)
result[field_name] = field_value
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "pydantic-xml"
version = "2.12.1"
version = "2.13.0"
description = "pydantic xml extension"
authors = ["Dmitry Pershin <[email protected]>"]
license = "Unlicense"
Expand Down
2 changes: 2 additions & 0 deletions tests/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ def test_snippets_py39(snippet: Path):

@pytest.fixture(
params=[
'computed-entities',
'custom-encoder',
'generic-model',
'quickstart',
'self-ref-model',
'xml-serialization',
],
)
def example_dir(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch):
Expand Down
56 changes: 56 additions & 0 deletions tests/test_preprocessors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from typing import List

from helpers import assert_xml_equal

from pydantic_xml import BaseXmlModel, element, xml_field_serializer, xml_field_validator
from pydantic_xml.element import XmlElementReader, XmlElementWriter


def test_xml_field_validator():
class TestModel(BaseXmlModel, tag='model1'):
element1: List[int] = element()

@xml_field_validator('element1')
def validate_element(cls, element: XmlElementReader, field_name: str) -> List[int]:
if element := element.pop_element(field_name, search_mode=cls.__xml_search_mode__):
return list(map(int, element.pop_text().split()))

return []

xml = '''
<model1>
<element1>1 2 3 4 5</element1>
</model1>
'''

actual_obj = TestModel.from_xml(xml)
expected_obj = TestModel(
element1=[1, 2, 3, 4, 5],
)

assert actual_obj == expected_obj


def test_xml_field_serializer():
class TestModel(BaseXmlModel, tag='model1'):
element1: List[int] = element()

@xml_field_serializer('element1')
def serialize_element(self, element: XmlElementWriter, value: List[int], field_name: str) -> None:
sub_element = element.make_element(tag=field_name, nsmap=None)
sub_element.set_text(' '.join(map(str, value)))

element.append_element(sub_element)

expected_xml = '''
<model1>
<element1>1 2 3 4 5</element1>
</model1>
'''

obj = TestModel(
element1=[1, 2, 3, 4, 5],
)

actual_xml = obj.to_xml()
assert_xml_equal(actual_xml, expected_xml)
Loading