diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4bd80a5..ce69365 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,12 @@ Changelog ========= +2.17.1 (2025-06-13) +------------------- + +- fix: multiple field annotations bug fixed. See https://github.com/dapper91/pydantic-xml/pull/268. + + 2.17.0 (2025-05-18) ------------------- diff --git a/README.rst b/README.rst index b661d50..ef0909e 100644 --- a/README.rst +++ b/README.rst @@ -31,7 +31,7 @@ Features -------- - pydantic v1 / v2 support -- flexable attributes, elements and text binding +- flexible attributes, elements and text binding - python collection types support (``Dict``, ``TypedDict``, ``List``, ``Set``, ``Tuple``, ...) - ``Union`` type support - pydantic `generic models `_ support diff --git a/docs/source/pages/data-binding/generics.rst b/docs/source/pages/data-binding/generics.rst index bf3f0a1..52838df 100644 --- a/docs/source/pages/data-binding/generics.rst +++ b/docs/source/pages/data-binding/generics.rst @@ -55,7 +55,7 @@ Generic xml model can be declared the same way: A generic model can be of one or more types and organized in a recursive structure. -The following example illustrate how to describes a flexable SOAP request model: +The following example illustrates how to describe a flexible SOAP request model: *model.py:* diff --git a/pydantic_xml/__init__.py b/pydantic_xml/__init__.py index 0881f5b..ca5e165 100644 --- a/pydantic_xml/__init__.py +++ b/pydantic_xml/__init__.py @@ -4,8 +4,9 @@ from . import config, errors, model from .errors import ModelError, ParsingError -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 +from .fields import XmlFieldSerializer, XmlFieldValidator, attr, computed_attr, computed_element, element, wrapped +from .fields import xml_field_serializer, xml_field_validator +from .model import BaseXmlModel, RootXmlModel, create_model __all__ = ( 'BaseXmlModel', diff --git a/pydantic_xml/fields.py b/pydantic_xml/fields.py new file mode 100644 index 0000000..3d071f0 --- /dev/null +++ b/pydantic_xml/fields.py @@ -0,0 +1,343 @@ +import dataclasses as dc +import typing +from typing import Any, Callable, Optional, Type, TypeVar, Union + +import pydantic as pd +import pydantic_core as pdc +from pydantic._internal._model_construction import ModelMetaclass # noqa +from pydantic.root_model import _RootModelMetaclass as RootModelMetaclass # noqa + +from . import config, model, utils +from .element import XmlElementReader, XmlElementWriter +from .typedefs import EntityLocation +from .utils import NsMap + +__all__ = ( + 'attr', + 'computed_attr', + 'computed_element', + 'computed_entity', + 'element', + 'wrapped', + 'xml_field_serializer', + 'xml_field_validator', + 'ComputedXmlEntityInfo', + 'SerializerFunc', + 'ValidatorFunc', + 'XmlEntityInfo', + 'XmlEntityInfoP', + 'XmlFieldSerializer', + 'XmlFieldValidator', +) + + +class XmlEntityInfoP(typing.Protocol): + location: Optional[EntityLocation] + path: Optional[str] + ns: Optional[str] + nsmap: Optional[NsMap] + nillable: Optional[bool] + wrapped: Optional['XmlEntityInfoP'] + + +class XmlEntityInfo(pd.fields.FieldInfo, XmlEntityInfoP): + """ + Field xml meta-information. + """ + + __slots__ = ('location', 'path', 'ns', 'nsmap', 'nillable', 'wrapped') + + @staticmethod + def merge_field_infos(*field_infos: pd.fields.FieldInfo, **overrides: Any) -> pd.fields.FieldInfo: + location, path, ns, nsmap, nillable, wrapped = None, None, None, None, None, None + + for field_info in field_infos: + if isinstance(field_info, XmlEntityInfo): + location = field_info.location if field_info.location is not None else location + path = field_info.path if field_info.path is not None else path + ns = field_info.ns if field_info.ns is not None else ns + nsmap = field_info.nsmap if field_info.nsmap is not None else nsmap + nillable = field_info.nillable if field_info.nillable is not None else nillable + wrapped = field_info.wrapped if field_info.wrapped is not None else wrapped + + field_info = pd.fields.FieldInfo.merge_field_infos(*field_infos, **overrides) + + xml_entity_info = XmlEntityInfo( + location, + path=path, + ns=ns, + nsmap=nsmap, + nillable=nillable, + wrapped=wrapped if isinstance(wrapped, XmlEntityInfo) else None, + **field_info._attributes_set, + ) + xml_entity_info.metadata = field_info.metadata + + return xml_entity_info + + def __init__( + self, + location: Optional[EntityLocation], + /, + path: Optional[str] = None, + ns: Optional[str] = None, + nsmap: Optional[NsMap] = None, + nillable: Optional[bool] = None, + wrapped: Optional[pd.fields.FieldInfo] = None, + **kwargs: Any, + ): + wrapped_metadata: list[Any] = [] + if wrapped is not None: + # copy arguments from the wrapped entity to let pydantic know how to process the field + for entity_field_name in utils.get_slots(wrapped): + if entity_field_name in pd.fields._FIELD_ARG_NAMES: + kwargs[entity_field_name] = getattr(wrapped, entity_field_name) + wrapped_metadata = wrapped.metadata + + if kwargs.get('serialization_alias') is None: + kwargs['serialization_alias'] = kwargs.get('alias') + + if kwargs.get('validation_alias') is None: + kwargs['validation_alias'] = kwargs.get('alias') + + super().__init__(**kwargs) + self.metadata.extend(wrapped_metadata) + + self.location = location + 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: + utils.register_nsmap(nsmap) + + +_Unset: Any = pdc.PydanticUndefined + + +def attr( + name: Optional[str] = None, + ns: Optional[str] = None, + *, + default: Any = pdc.PydanticUndefined, + default_factory: Optional[Callable[[], Any]] = _Unset, + **kwargs: Any, +) -> Any: + """ + Marks a pydantic field as an xml attribute. + + :param name: attribute name + :param ns: attribute xml namespace + :param default: the default value of the field. + :param default_factory: the factory function used to construct the default for the field. + :param kwargs: pydantic field arguments. See :py:class:`pydantic.Field` + """ + + return XmlEntityInfo( + EntityLocation.ATTRIBUTE, + path=name, ns=ns, default=default, default_factory=default_factory, + **kwargs, + ) + + +def element( + tag: Optional[str] = None, + ns: Optional[str] = None, + nsmap: Optional[NsMap] = None, + nillable: Optional[bool] = None, + *, + default: Any = pdc.PydanticUndefined, + default_factory: Optional[Callable[[], Any]] = _Unset, + **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 default: the default value of the field. + :param default_factory: the factory function used to construct the default for the field. + :param kwargs: pydantic field arguments. See :py:class:`pydantic.Field` + """ + + return XmlEntityInfo( + EntityLocation.ELEMENT, + path=tag, ns=ns, nsmap=nsmap, nillable=nillable, default=default, default_factory=default_factory, + **kwargs, + ) + + +def wrapped( + path: str, + entity: Optional[pd.fields.FieldInfo] = None, + ns: Optional[str] = None, + nsmap: Optional[NsMap] = None, + *, + default: Any = pdc.PydanticUndefined, + default_factory: Optional[Callable[[], Any]] = _Unset, + **kwargs: Any, +) -> Any: + """ + Marks a pydantic field as a wrapped xml entity. + + :param entity: wrapped entity + :param path: entity path + :param ns: element xml namespace + :param nsmap: element xml namespace map + :param default: the default value of the field. + :param default_factory: the factory function used to construct the default for the field. + :param kwargs: pydantic field arguments. See :py:class:`pydantic.Field` + """ + + return XmlEntityInfo( + EntityLocation.WRAPPED, + path=path, ns=ns, nsmap=nsmap, wrapped=entity, default=default, default_factory=default_factory, + **kwargs, + ) + + +@dc.dataclass +class ComputedXmlEntityInfo(pd.fields.ComputedFieldInfo, XmlEntityInfoP): + """ + Computed field xml meta-information. + """ + + __slots__ = ('location', 'path', 'ns', 'nsmap', 'nillable', 'wrapped') + + location: Optional[EntityLocation] + path: Optional[str] + ns: Optional[str] + nsmap: Optional[NsMap] + nillable: Optional[bool] + wrapped: Optional[XmlEntityInfoP] # to be compliant with XmlEntityInfoP protocol + + def __post_init__(self) -> None: + if config.REGISTER_NS_PREFIXES and self.nsmap: + utils.register_nsmap(self.nsmap) + + +PropertyT = typing.TypeVar('PropertyT') + + +def computed_entity( + location: EntityLocation, + prop: Optional[PropertyT] = None, + **kwargs: Any, +) -> Union[PropertyT, Callable[[PropertyT], PropertyT]]: + def decorator(prop: Any) -> Any: + path = kwargs.pop('path', None) + ns = kwargs.pop('ns', None) + nsmap = kwargs.pop('nsmap', None) + nillable = kwargs.pop('nillable', None) + + 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), + ) + + return descriptor_proxy + + if prop is None: + return decorator + else: + return decorator(prop) + + +def computed_attr( + prop: Optional[PropertyT] = None, + *, + name: Optional[str] = None, + ns: Optional[str] = None, + **kwargs: Any, +) -> Union[PropertyT, Callable[[PropertyT], PropertyT]]: + """ + Marks a property as an xml attribute. + + :param prop: decorated property + :param name: attribute name + :param ns: attribute xml namespace + :param kwargs: pydantic computed field arguments. See :py:class:`pydantic.computed_field` + """ + + return computed_entity(EntityLocation.ATTRIBUTE, prop, path=name, ns=ns, **kwargs) + + +def computed_element( + prop: Optional[PropertyT] = None, + *, + tag: Optional[str] = None, + ns: Optional[str] = None, + nsmap: Optional[NsMap] = None, + nillable: Optional[bool] = None, + **kwargs: Any, +) -> Union[PropertyT, Callable[[PropertyT], PropertyT]]: + """ + Marks a property as an xml element. + + :param prop: decorated property + :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, nillable=nillable, **kwargs) + + +ValidatorFunc = Callable[[Type['model.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[['model.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 + + +@dc.dataclass(frozen=True) +class XmlFieldValidator: + func: ValidatorFunc + + +@dc.dataclass(frozen=True) +class XmlFieldSerializer: + func: SerializerFunc diff --git a/pydantic_xml/model.py b/pydantic_xml/model.py index f2475c4..73c84e3 100644 --- a/pydantic_xml/model.py +++ b/pydantic_xml/model.py @@ -1,6 +1,5 @@ -import dataclasses as dc import typing -from typing import Any, Callable, ClassVar, Dict, Generic, Optional, Tuple, Type, TypeVar, Union +from typing import Any, ClassVar, Dict, Generic, Optional, Tuple, Type, TypeVar, Union import pydantic as pd import pydantic_core as pdc @@ -10,251 +9,23 @@ from pydantic.root_model import _RootModelMetaclass as RootModelMetaclass # noqa from . import config, errors, utils -from .element import SearchMode, XmlElementReader, XmlElementWriter +from .element import SearchMode from .element.native import ElementT, XmlElement, etree +from .fields import SerializerFunc, ValidatorFunc, XmlEntityInfo, XmlFieldSerializer, XmlFieldValidator, attr, element +from .fields import wrapped from .serializers.factories.model import BaseModelSerializer -from .serializers.serializer import Serializer, XmlEntityInfoP +from .serializers.serializer import Serializer from .typedefs import EntityLocation from .utils import NsMap __all__ = ( - 'attr', - 'create_model', - 'element', - 'wrapped', - 'computed_attr', - 'computed_element', - 'xml_field_serializer', - 'xml_field_validator', - 'XmlFieldSerializer', - 'XmlFieldValidator', 'BaseXmlModel', + 'create_model', 'RootXmlModel', + 'XmlModelMeta', ) -@dc.dataclass -class ComputedXmlEntityInfo(pd.fields.ComputedFieldInfo): - """ - Computed field xml meta-information. - """ - - __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: - if config.REGISTER_NS_PREFIXES and self.nsmap: - utils.register_nsmap(self.nsmap) - - -PropertyT = typing.TypeVar('PropertyT') - - -def computed_entity( - location: EntityLocation, - prop: Optional[PropertyT] = None, - **kwargs: Any, -) -> Union[PropertyT, Callable[[PropertyT], PropertyT]]: - 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), - ) - - return descriptor_proxy - - if prop is None: - return decorator - else: - return decorator(prop) - - -def computed_attr( - prop: Optional[PropertyT] = None, - *, - name: Optional[str] = None, - ns: Optional[str] = None, - **kwargs: Any, -) -> Union[PropertyT, Callable[[PropertyT], PropertyT]]: - """ - Marks a property as an xml attribute. - - :param prop: decorated property - :param name: attribute name - :param ns: attribute xml namespace - :param kwargs: pydantic computed field arguments. See :py:class:`pydantic.computed_field` - """ - - return computed_entity(EntityLocation.ATTRIBUTE, prop, path=name, ns=ns, **kwargs) - - -def computed_element( - prop: Optional[PropertyT] = None, - *, - tag: Optional[str] = None, - ns: Optional[str] = None, - nsmap: Optional[NsMap] = None, - nillable: bool = False, - **kwargs: Any, -) -> Union[PropertyT, Callable[[PropertyT], PropertyT]]: - """ - Marks a property as an xml element. - - :param prop: decorated property - :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, nillable=nillable, **kwargs) - - -class XmlEntityInfo(pd.fields.FieldInfo): - """ - Field xml meta-information. - """ - - __slots__ = ('location', 'path', 'ns', 'nsmap', 'nillable', 'wrapped') - - def __init__( - self, - location: Optional[EntityLocation], - /, - path: Optional[str] = None, - ns: Optional[str] = None, - nsmap: Optional[NsMap] = None, - nillable: bool = False, - wrapped: Optional[pd.fields.FieldInfo] = None, - **kwargs: Any, - ): - if wrapped is not None: - # copy arguments from the wrapped entity to let pydantic know how to process the field - for entity_field_name in utils.get_slots(wrapped): - kwargs[entity_field_name] = getattr(wrapped, entity_field_name) - - if kwargs.get('serialization_alias') is None: - kwargs['serialization_alias'] = kwargs.get('alias') - - if kwargs.get('validation_alias') is None: - kwargs['validation_alias'] = kwargs.get('alias') - - super().__init__(**kwargs) - self.location = location - 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: - utils.register_nsmap(nsmap) - - -_Unset: Any = pdc.PydanticUndefined - - -def attr( - name: Optional[str] = None, - ns: Optional[str] = None, - *, - default: Any = pdc.PydanticUndefined, - default_factory: Optional[Callable[[], Any]] = _Unset, - **kwargs: Any, -) -> Any: - """ - Marks a pydantic field as an xml attribute. - - :param name: attribute name - :param ns: attribute xml namespace - :param default: the default value of the field. - :param default_factory: the factory function used to construct the default for the field. - :param kwargs: pydantic field arguments. See :py:class:`pydantic.Field` - """ - - return XmlEntityInfo( - EntityLocation.ATTRIBUTE, - path=name, ns=ns, default=default, default_factory=default_factory, - **kwargs, - ) - - -def element( - tag: Optional[str] = None, - ns: Optional[str] = None, - nsmap: Optional[NsMap] = None, - nillable: bool = False, - *, - default: Any = pdc.PydanticUndefined, - default_factory: Optional[Callable[[], Any]] = _Unset, - **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 default: the default value of the field. - :param default_factory: the factory function used to construct the default for the field. - :param kwargs: pydantic field arguments. See :py:class:`pydantic.Field` - """ - - return XmlEntityInfo( - EntityLocation.ELEMENT, - path=tag, ns=ns, nsmap=nsmap, nillable=nillable, default=default, default_factory=default_factory, - **kwargs, - ) - - -def wrapped( - path: str, - entity: Optional[pd.fields.FieldInfo] = None, - ns: Optional[str] = None, - nsmap: Optional[NsMap] = None, - *, - default: Any = pdc.PydanticUndefined, - default_factory: Optional[Callable[[], Any]] = _Unset, - **kwargs: Any, -) -> Any: - """ - Marks a pydantic field as a wrapped xml entity. - - :param entity: wrapped entity - :param path: entity path - :param ns: element xml namespace - :param nsmap: element xml namespace map - :param default: the default value of the field. - :param default_factory: the factory function used to construct the default for the field. - :param kwargs: pydantic field arguments. See :py:class:`pydantic.Field` - """ - - return XmlEntityInfo( - EntityLocation.WRAPPED, - path=path, ns=ns, nsmap=nsmap, wrapped=entity, default=default, default_factory=default_factory, - **kwargs, - ) - - Model = TypeVar('Model', bound='BaseXmlModel') @@ -319,54 +90,6 @@ 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 - - -@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): """ diff --git a/pydantic_xml/serializers/factories/model.py b/pydantic_xml/serializers/factories/model.py index de5f568..140970f 100644 --- a/pydantic_xml/serializers/factories/model.py +++ b/pydantic_xml/serializers/factories/model.py @@ -9,7 +9,8 @@ import pydantic_xml as pxml from pydantic_xml import errors, utils 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.fields import ComputedXmlEntityInfo, XmlEntityInfoP +from pydantic_xml.serializers.serializer import SearchMode, Serializer from pydantic_xml.typedefs import EntityLocation, Location, NsMap from pydantic_xml.utils import QName, merge_nsmaps, select_ns @@ -95,7 +96,7 @@ def from_core_schema(cls, schema: pcs.ModelSchema, ctx: Serializer.Context) -> ' field_alias = model_field.get('alias') computed_field_info = model_cls.__pydantic_decorators__.computed_fields[field_name].info - if isinstance(computed_field_info, pxml.model.ComputedXmlEntityInfo): + if isinstance(computed_field_info, ComputedXmlEntityInfo): entity_info = computed_field_info else: entity_info = None @@ -356,7 +357,7 @@ def __init__( nsmap: Optional[NsMap], search_mode: SearchMode, computed: bool, - nillable: bool, + nillable: Optional[bool], ): self._model = model self._element_name = QName.from_alias(tag=name, ns=ns, nsmap=nsmap).uri diff --git a/pydantic_xml/serializers/factories/primitive.py b/pydantic_xml/serializers/factories/primitive.py index fa94a6d..ce0e792 100644 --- a/pydantic_xml/serializers/factories/primitive.py +++ b/pydantic_xml/serializers/factories/primitive.py @@ -36,7 +36,7 @@ def from_core_schema(cls, schema: PrimitiveTypeSchema, ctx: Serializer.Context) return cls(computed, nillable) - def __init__(self, computed: bool, nillable: bool): + def __init__(self, computed: bool, nillable: Optional[bool]): self._computed = computed self._nillable = nillable @@ -163,7 +163,7 @@ def __init__( nsmap: Optional[NsMap], search_mode: SearchMode, computed: bool, - nillable: bool, + nillable: Optional[bool], ): super().__init__(computed, nillable) diff --git a/pydantic_xml/serializers/serializer.py b/pydantic_xml/serializers/serializer.py index e61b894..72a590d 100644 --- a/pydantic_xml/serializers/serializer.py +++ b/pydantic_xml/serializers/serializer.py @@ -10,6 +10,7 @@ from pydantic_xml.element import SearchMode, XmlElementReader, XmlElementWriter from pydantic_xml.errors import ModelError +from pydantic_xml.fields import XmlEntityInfoP from pydantic_xml.typedefs import EntityLocation, Location, NsMap from pydantic_xml.utils import select_ns @@ -93,15 +94,6 @@ class SchemaTypeFamily(IntEnum): } -class XmlEntityInfoP(typing.Protocol): - location: Optional[EntityLocation] - path: Optional[str] - ns: Optional[str] - nsmap: Optional[NsMap] - nillable: bool - wrapped: Optional['XmlEntityInfoP'] - - class Serializer(abc.ABC): @dc.dataclass(frozen=True) class Context: @@ -141,11 +133,11 @@ 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: + def nillable(self) -> Optional[bool]: return self.entity_info.nillable if self.entity_info is not None else False @property - def entity_wrapped(self) -> Optional['XmlEntityInfoP']: + def entity_wrapped(self) -> Optional[XmlEntityInfoP]: return self.entity_info.wrapped if self.entity_info is not None else None @cached_property diff --git a/pyproject.toml b/pyproject.toml index cae6ec8..932a6bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pydantic-xml" -version = "2.17.0" +version = "2.17.1" description = "pydantic xml extension" authors = ["Dmitry Pershin "] license = "Unlicense" diff --git a/tests/test_misc.py b/tests/test_misc.py index 2b9f9ae..02bc8a0 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -1,3 +1,4 @@ +import sys from typing import Dict, List, Optional, Tuple, Union import pydantic as pd @@ -5,6 +6,7 @@ from helpers import assert_xml_equal from pydantic_xml import BaseXmlModel, RootXmlModel, attr, element, errors, wrapped +from pydantic_xml.fields import XmlEntityInfo def test_xml_declaration(): @@ -377,3 +379,30 @@ def validate_field(cls, v: str, info: pd.FieldValidationInfo): ''' TestModel.from_xml(xml, validation_context) + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason="requires python 3.9 and above") +def test_field_info_merge(): + from typing import Annotated + + from annotated_types import Ge, Lt + + class TestModel(BaseXmlModel, tag='root'): + element1: Annotated[ + int, + pd.Field(ge=0), + pd.Field(default=0, lt=100), + element(nillable=True), + ] = element(tag='elm', lt=10) + + field_info = TestModel.model_fields['element1'] + assert isinstance(field_info, XmlEntityInfo) + assert field_info.metadata == [Ge(ge=0), Lt(lt=10)] + assert field_info.default == 0 + assert field_info.nillable == True + assert field_info.path == 'elm' + + TestModel.from_xml("0") + + with pytest.raises(pd.ValidationError): + TestModel.from_xml("-1") diff --git a/tests/test_wrapped.py b/tests/test_wrapped.py index 411b565..551dc8f 100644 --- a/tests/test_wrapped.py +++ b/tests/test_wrapped.py @@ -1,5 +1,7 @@ from typing import Dict, List, Optional +import pydantic as pd +import pytest from helpers import assert_xml_equal from pydantic_xml import BaseXmlModel, RootXmlModel, attr, element, wrapped @@ -28,6 +30,30 @@ class TestModel(BaseXmlModel, tag='model1'): assert_xml_equal(actual_xml, xml) +@pytest.mark.parametrize( + 'value, field_gt, wrapped_gt, should_fail', + [ + (1, 1, 0, True), + (1, 1, None, True), + (1, None, 1, True), + (1, 0, 1, False), + ], +) +def test_wrapped_field(value: int, field_gt: Optional[int], wrapped_gt: Optional[int], should_fail: bool): + class TestModel(BaseXmlModel, tag='model1'): + data: int = wrapped('model2', pd.Field(gt=field_gt), gt=wrapped_gt) + + xml = f''' + {value} + ''' + + if should_fail: + with pytest.raises(pd.ValidationError): + TestModel.from_xml(xml) + else: + TestModel.from_xml(xml) + + def test_wrapped_path_merge(): class TestModel(BaseXmlModel, tag='model1'): element0: int = element(tag='element0')