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/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):