diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 0c96b3f..4bd80a5 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -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)
-------------------
diff --git a/docs/source/pages/misc.rst b/docs/source/pages/misc.rst
index 4a8dc1f..7912e87 100644
--- a/docs/source/pages/misc.rst
+++ b/docs/source/pages/misc.rst
@@ -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
~~~~~~~~~~~~~~~~~~~~~~
diff --git a/examples/xml-serialization/doc.xml b/examples/xml-serialization-annotation/doc.xml
similarity index 100%
rename from examples/xml-serialization/doc.xml
rename to examples/xml-serialization-annotation/doc.xml
diff --git a/examples/xml-serialization-annotation/model.py b/examples/xml-serialization-annotation/model.py
new file mode 100644
index 0000000..8753d3e
--- /dev/null
+++ b/examples/xml-serialization-annotation/model.py
@@ -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)
diff --git a/examples/xml-serialization-decorator/doc.xml b/examples/xml-serialization-decorator/doc.xml
new file mode 100644
index 0000000..d1426bc
--- /dev/null
+++ b/examples/xml-serialization-decorator/doc.xml
@@ -0,0 +1,4 @@
+
+ 0.0 1.0 2.0 3.0 4.0 5.0
+ 0.0 3.2 5.4 4.1 2.0 -1.2
+
diff --git a/examples/xml-serialization/model.py b/examples/xml-serialization-decorator/model.py
similarity index 100%
rename from examples/xml-serialization/model.py
rename to examples/xml-serialization-decorator/model.py
diff --git a/pydantic_xml/__init__.py b/pydantic_xml/__init__.py
index 5f25f10..0881f5b 100644
--- a/pydantic_xml/__init__.py
+++ b/pydantic_xml/__init__.py
@@ -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',
@@ -22,4 +22,6 @@
'model',
'xml_field_serializer',
'xml_field_validator',
+ 'XmlFieldValidator',
+ 'XmlFieldSerializer',
)
diff --git a/pydantic_xml/model.py b/pydantic_xml/model.py
index 2d65476..f2475c4 100644
--- a/pydantic_xml/model.py
+++ b/pydantic_xml/model.py
@@ -26,6 +26,8 @@
'computed_element',
'xml_field_serializer',
'xml_field_validator',
+ 'XmlFieldSerializer',
+ 'XmlFieldValidator',
'BaseXmlModel',
'RootXmlModel',
)
@@ -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):
"""
@@ -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')
@@ -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:
diff --git a/pyproject.toml b/pyproject.toml
index d9b3176..cae6ec8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "pydantic-xml"
-version = "2.16.0"
+version = "2.17.0"
description = "pydantic xml extension"
authors = ["Dmitry Pershin "]
license = "Unlicense"
diff --git a/tests/test_examples.py b/tests/test_examples.py
index 5cb2c8c..81edd9e 100644
--- a/tests/test_examples.py
+++ b/tests/test_examples.py
@@ -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):