Skip to content
Merged

Dev #262

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.17.0 (2025-05-18)
-------------------

- feat: annotated form of field serializer/validator support added. See https://github.com/dapper91/pydantic-xml/pull/261.


2.16.0 (2025-04-20)
-------------------

Expand Down
12 changes: 10 additions & 2 deletions docs/source/pages/misc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,23 @@ The following example illustrate how to serialize ``xs:list`` element:

*model.py:*

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

*doc.xml:*

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


``pydantic-xml`` also supports the ``Annotated`` typing form to attach metadata to an annotation:

*model.py:*

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


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

Expand Down
47 changes: 47 additions & 0 deletions examples/xml-serialization-annotation/model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import pathlib
from typing import Annotated, List, Type
from xml.etree.ElementTree import canonicalize

import pydantic_xml as pxml
from pydantic_xml.element import XmlElementReader, XmlElementWriter


def validate_space_separated_list(
cls: Type[pxml.BaseXmlModel],
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 []


def serialize_space_separated_list(
model: pxml.BaseXmlModel,
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)


SpaceSeparatedValueList = Annotated[
List[float],
pxml.XmlFieldValidator(validate_space_separated_list),
pxml.XmlFieldSerializer(serialize_space_separated_list),
]


class Plot(pxml.BaseXmlModel):
x: SpaceSeparatedValueList = pxml.element()
y: SpaceSeparatedValueList = pxml.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)
4 changes: 4 additions & 0 deletions examples/xml-serialization-decorator/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>
6 changes: 4 additions & 2 deletions pydantic_xml/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

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
from .model import BaseXmlModel, RootXmlModel, XmlFieldSerializer, XmlFieldValidator, attr, computed_attr
from .model import computed_element, create_model, element, wrapped, xml_field_serializer, xml_field_validator

__all__ = (
'BaseXmlModel',
Expand All @@ -22,4 +22,6 @@
'model',
'xml_field_serializer',
'xml_field_validator',
'XmlFieldValidator',
'XmlFieldSerializer',
)
49 changes: 36 additions & 13 deletions pydantic_xml/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
'computed_element',
'xml_field_serializer',
'xml_field_validator',
'XmlFieldSerializer',
'XmlFieldValidator',
'BaseXmlModel',
'RootXmlModel',
)
Expand Down Expand Up @@ -355,6 +357,16 @@ def wrapper(func: SerializerFuncT) -> SerializerFuncT:
return wrapper


@dc.dataclass(frozen=True)
class XmlFieldValidator:
func: ValidatorFunc


@dc.dataclass(frozen=True)
class XmlFieldSerializer:
func: SerializerFunc


@te.dataclass_transform(kw_only_default=True, field_specifiers=(attr, element, wrapped, pd.Field))
class XmlModelMeta(ModelMetaclass):
"""
Expand All @@ -374,8 +386,32 @@ def __new__(
if not is_abstract:
cls.__build_serializer__()

cls._collect_xml_field_serializers_validators(cls)

return cls

@classmethod
def _collect_xml_field_serializers_validators(mcls, cls: Type['BaseXmlModel']) -> None:
for field_name, field_info in cls.model_fields.items():
for metadatum in field_info.metadata:
if isinstance(metadatum, XmlFieldValidator):
cls.__xml_field_validators__[field_name] = metadatum.func
if isinstance(metadatum, XmlFieldSerializer):
cls.__xml_field_serializers__[field_name] = metadatum.func

# find custom validators/serializers in all defined attributes
# though we want to skip any BaseModel attributes, as these can never be field
# serializers/validators, and getting certain pydantic fields
# may cause recursion errors for recursive / self-referential models
for attr_name in set(dir(cls)) - set(dir(BaseModel)):
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


ModelT = TypeVar('ModelT', bound='BaseXmlModel')

Expand Down Expand Up @@ -435,19 +471,6 @@ def __init_subclass__(
cls.__xml_field_serializers__ = {}
cls.__xml_field_validators__ = {}

# find custom validators/serializers in all defined attributes
# though we want to skip any Base(Xml)Model attributes, as these can never be field
# serializers/validators, and getting certain pydantic fields, like __pydantic_post_init__
# may cause recursion errors for recursive / self-referential models
for attr_name in set(dir(cls)) - set(dir(BaseXmlModel)):
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
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.16.0"
version = "2.17.0"
description = "pydantic xml extension"
authors = ["Dmitry Pershin <dapper1291@gmail.com>"]
license = "Unlicense"
Expand Down
6 changes: 5 additions & 1 deletion tests/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ def test_snippets_py39(snippet: Path):
'generic-model',
'quickstart',
'self-ref-model',
'xml-serialization',
'xml-serialization-decorator',
pytest.param(
'xml-serialization-annotation',
marks=pytest.mark.skipif(sys.version_info < (3, 9), reason="requires python 3.9 and above"),
),
],
)
def example_dir(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch):
Expand Down