diff --git a/docs/source/pages/data-binding/elements.rst b/docs/source/pages/data-binding/elements.rst index f8af83a..c1527f1 100644 --- a/docs/source/pages/data-binding/elements.rst +++ b/docs/source/pages/data-binding/elements.rst @@ -206,7 +206,7 @@ choice because it works in predictable time since it doesn't require any look-ah .. grid-item-card:: Model - .. literalinclude:: ../../../../examples/snippets/model_mode_strict.py + .. literalinclude:: ../../../../examples/snippets/lxml/model_mode_strict.py :language: python :start-after: model-start :end-before: model-end @@ -220,7 +220,7 @@ choice because it works in predictable time since it doesn't require any look-ah .. tab-item:: XML - .. literalinclude:: ../../../../examples/snippets/model_mode_strict.py + .. literalinclude:: ../../../../examples/snippets/lxml/model_mode_strict.py :language: xml :lines: 2- :start-after: xml-start @@ -228,7 +228,7 @@ choice because it works in predictable time since it doesn't require any look-ah .. tab-item:: JSON - .. literalinclude:: ../../../../examples/snippets/model_mode_strict.py + .. literalinclude:: ../../../../examples/snippets/lxml/model_mode_strict.py :language: json :lines: 2- :start-after: json-start diff --git a/examples/snippets/model_mode_strict.py b/examples/snippets/lxml/model_mode_strict.py similarity index 87% rename from examples/snippets/model_mode_strict.py rename to examples/snippets/lxml/model_mode_strict.py index 82cb695..69346ae 100644 --- a/examples/snippets/model_mode_strict.py +++ b/examples/snippets/lxml/model_mode_strict.py @@ -35,10 +35,10 @@ class Company( error = e.errors()[0] assert error == { 'loc': ('founded',), - 'msg': 'Field required', + 'msg': '[line 2]: Field required', + 'ctx': {'orig': 'Field required', 'sourceline': 2}, 'type': 'missing', 'input': ANY, - 'url': ANY, } else: raise AssertionError('exception not raised') diff --git a/pydantic_xml/element/element.py b/pydantic_xml/element/element.py index e324b93..4a99e30 100644 --- a/pydantic_xml/element/element.py +++ b/pydantic_xml/element/element.py @@ -4,6 +4,9 @@ from pydantic_xml.typedefs import NsMap +PathElementT = TypeVar('PathElementT') +PathT = Tuple[PathElementT, ...] + class XmlElementReader(abc.ABC): """ @@ -90,7 +93,7 @@ def pop_element(self, tag: str, search_mode: 'SearchMode') -> Optional['XmlEleme """ @abc.abstractmethod - def find_sub_element(self, path: Sequence[str], search_mode: 'SearchMode') -> Optional['XmlElementReader']: + def find_sub_element(self, path: Sequence[str], search_mode: 'SearchMode') -> PathT['XmlElementReader']: """ Searches for an element at the provided path. If the element is not found returns `None`. @@ -122,13 +125,21 @@ def to_native(self) -> Any: """ @abc.abstractmethod - def get_unbound(self) -> List[Tuple[Tuple[str, ...], str]]: + def get_unbound(self) -> List[Tuple[PathT['XmlElementReader'], Optional[str], str]]: """ Returns unbound entities. :return: list of unbound entities """ + @abc.abstractmethod + def get_sourceline(self) -> int: + """ + Returns source line of the element in the xml document. + + :return: source line + """ + class XmlElementWriter(abc.ABC): """ @@ -265,6 +276,7 @@ def __init__( attributes: Optional[Dict[str, str]] = None, elements: Optional[List['XmlElement[NativeElement]']] = None, nsmap: Optional[NsMap] = None, + sourceline: int = -1, ): self._tag = tag self._nsmap = nsmap @@ -275,6 +287,11 @@ def __init__( elements=elements or [], next_element_idx=0, ) + self._sourceline = sourceline + + @abc.abstractmethod + def get_sourceline(self) -> int: + return self._sourceline @property def tag(self) -> str: @@ -345,15 +362,17 @@ def pop_element(self, tag: str, search_mode: 'SearchMode') -> Optional['XmlEleme return searcher(self._state, tag, False, True) - def find_sub_element(self, path: Sequence[str], search_mode: 'SearchMode') -> Optional['XmlElement[NativeElement]']: + def find_sub_element(self, path: Sequence[str], search_mode: 'SearchMode') -> PathT['XmlElement[NativeElement]']: assert len(path) > 0, "path can't be empty" - root, path = path[0], path[1:] - element = self.find_element(root, search_mode) - if element and path: - return element.find_sub_element(path, search_mode) - - return element + root, *path = path + if (element := self.find_element(root, search_mode)) is not None: + if path: + return (element,) + element.find_sub_element(path, search_mode) + else: + return (element,) + else: + return () def find_element_or_create( self, @@ -379,21 +398,24 @@ def find_element( return searcher(self._state, tag, look_behind, step_forward) - def get_unbound(self, path: Tuple[str, ...] = ()) -> List[Tuple[Tuple[str, ...], str]]: - result: List[Tuple[Tuple[str, ...], str]] = [] + def get_unbound( + self, + path: PathT[XmlElementReader] = (), + ) -> List[Tuple[PathT[XmlElementReader], Optional[str], str]]: + result: List[Tuple[PathT[XmlElementReader], Optional[str], str]] = [] if self._state.text and (text := self._state.text.strip()): - result.append((path, text)) + result.append((path, None, text)) if self._state.tail and (tail := self._state.tail.strip()): - result.append((path, tail)) + result.append((path, None, tail)) if attrs := self._state.attrib: for name, value in attrs.items(): - result.append((path + (f'@{name}',), value)) + result.append((path, name, value)) for sub_element in self._state.elements: - result.extend(sub_element.get_unbound(path + (sub_element.tag,))) + result.extend(sub_element.get_unbound(path + (sub_element,))) return result diff --git a/pydantic_xml/element/native/lxml.py b/pydantic_xml/element/native/lxml.py index b8a0d74..433f553 100644 --- a/pydantic_xml/element/native/lxml.py +++ b/pydantic_xml/element/native/lxml.py @@ -1,3 +1,4 @@ +import typing from typing import Optional, Union from lxml import etree @@ -30,6 +31,7 @@ def from_native(cls, element: ElementT) -> 'XmlElement': for sub_element in element if not is_xml_comment(sub_element) ], + sourceline=typing.cast(int, element.sourceline) if element.sourceline is not None else -1, ) def to_native(self) -> ElementT: @@ -48,6 +50,9 @@ def to_native(self) -> ElementT: def make_element(self, tag: str, nsmap: Optional[NsMap]) -> 'XmlElement': return XmlElement(tag, nsmap=nsmap) + def get_sourceline(self) -> int: + return self._sourceline + def force_str(val: Union[str, bytes]) -> str: if isinstance(val, bytes): diff --git a/pydantic_xml/element/native/std.py b/pydantic_xml/element/native/std.py index 0399526..f55f22c 100644 --- a/pydantic_xml/element/native/std.py +++ b/pydantic_xml/element/native/std.py @@ -39,6 +39,9 @@ def to_native(self) -> ElementT: def make_element(self, tag: str, nsmap: Optional[NsMap]) -> 'XmlElement': return XmlElement(tag) + def get_sourceline(self) -> int: + return -1 + def is_xml_comment(element: ElementT) -> bool: return element.tag is etree.Comment # type: ignore[comparison-overlap] diff --git a/pydantic_xml/model.py b/pydantic_xml/model.py index 4bd3fb6..2fb2cdf 100644 --- a/pydantic_xml/model.py +++ b/pydantic_xml/model.py @@ -340,7 +340,14 @@ def from_xml_tree(cls: Type[ModelT], root: etree.Element, context: Optional[Dict assert cls.__xml_serializer__ is not None, f"model {cls.__name__} is partially initialized" if root.tag == cls.__xml_serializer__.element_name: - obj = typing.cast(ModelT, cls.__xml_serializer__.deserialize(XmlElement.from_native(root), context=context)) + obj = typing.cast( + ModelT, cls.__xml_serializer__.deserialize( + XmlElement.from_native(root), + context=context, + sourcemap={}, + loc=(), + ), + ) return obj else: raise errors.ParsingError( diff --git a/pydantic_xml/serializers/factories/heterogeneous.py b/pydantic_xml/serializers/factories/heterogeneous.py index 8ec6e29..7214327 100644 --- a/pydantic_xml/serializers/factories/heterogeneous.py +++ b/pydantic_xml/serializers/factories/heterogeneous.py @@ -1,24 +1,27 @@ -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, Union +import pydantic as pd from pydantic_core import core_schema as pcs -from pydantic_xml import errors +from pydantic_xml import errors, utils from pydantic_xml.element import XmlElementReader, XmlElementWriter from pydantic_xml.serializers.serializer import TYPE_FAMILY, SchemaTypeFamily, Serializer -from pydantic_xml.typedefs import EntityLocation +from pydantic_xml.typedefs import EntityLocation, Location class ElementSerializer(Serializer): @classmethod def from_core_schema(cls, schema: pcs.TuplePositionalSchema, ctx: Serializer.Context) -> 'ElementSerializer': + model_name = ctx.model_name computed = ctx.field_computed inner_serializers: List[Serializer] = [] for item_schema in schema['items_schema']: inner_serializers.append(Serializer.parse_core_schema(item_schema, ctx)) - return cls(computed, tuple(inner_serializers)) + return cls(model_name, computed, tuple(inner_serializers)) - def __init__(self, computed: bool, inner_serializers: Tuple[Serializer, ...]): + def __init__(self, model_name: str, computed: bool, inner_serializers: Tuple[Serializer, ...]): + self._model_name = model_name self._computed = computed self._inner_serializers = inner_serializers @@ -44,6 +47,8 @@ def deserialize( element: Optional[XmlElementReader], *, context: Optional[Dict[str, Any]], + sourcemap: Dict[Location, int], + loc: Location, ) -> Optional[List[Any]]: if self._computed: return None @@ -51,10 +56,17 @@ def deserialize( if element is None: return None - result = [ - serializer.deserialize(element, context=context) - for serializer in self._inner_serializers - ] + result: List[Any] = [] + item_errors: Dict[Union[None, str, int], pd.ValidationError] = {} + for idx, serializer in enumerate(self._inner_serializers): + try: + result.append(serializer.deserialize(element, context=context, sourcemap=sourcemap, loc=loc + (idx,))) + except pd.ValidationError as err: + item_errors[idx] = err + + if item_errors: + raise utils.build_validation_error(title=self._model_name, errors_map=item_errors) + if all((value is None for value in result)): return None else: diff --git a/pydantic_xml/serializers/factories/homogeneous.py b/pydantic_xml/serializers/factories/homogeneous.py index df64467..afb07be 100644 --- a/pydantic_xml/serializers/factories/homogeneous.py +++ b/pydantic_xml/serializers/factories/homogeneous.py @@ -1,11 +1,13 @@ +import itertools as it from typing import Any, Dict, List, Optional, Union +import pydantic as pd from pydantic_core import core_schema as pcs -from pydantic_xml import errors +from pydantic_xml import errors, utils from pydantic_xml.element import XmlElementReader, XmlElementWriter from pydantic_xml.serializers.serializer import TYPE_FAMILY, SchemaTypeFamily, Serializer -from pydantic_xml.typedefs import EntityLocation +from pydantic_xml.typedefs import EntityLocation, Location HomogeneousCollectionTypeSchema = Union[ pcs.TupleVariableSchema, @@ -18,12 +20,14 @@ class ElementSerializer(Serializer): @classmethod def from_core_schema(cls, schema: HomogeneousCollectionTypeSchema, ctx: Serializer.Context) -> 'ElementSerializer': + model_name = ctx.model_name computed = ctx.field_computed inner_serializer = Serializer.parse_core_schema(schema['items_schema'], ctx) - return cls(computed, inner_serializer) + return cls(model_name, computed, inner_serializer) - def __init__(self, computed: bool, inner_serializer: Serializer): + def __init__(self, model_name: str, computed: bool, inner_serializer: Serializer): + self._model_name = model_name self._computed = computed self._inner_serializer = inner_serializer @@ -49,6 +53,8 @@ def deserialize( element: Optional[XmlElementReader], *, context: Optional[Dict[str, Any]], + sourcemap: Dict[Location, int], + loc: Location, ) -> Optional[List[Any]]: if self._computed: return None @@ -56,9 +62,21 @@ def deserialize( if element is None: return None - result = [] - while (value := self._inner_serializer.deserialize(element, context=context)) is not None: - result.append(value) + serializer = self._inner_serializer + result: List[Any] = [] + item_errors: Dict[Union[None, str, int], pd.ValidationError] = {} + for idx in it.count(): + try: + value = serializer.deserialize(element, context=context, sourcemap=sourcemap, loc=loc + (idx,)) + if value is None: + break + except pd.ValidationError as err: + item_errors[idx] = err + else: + result.append(value) + + if item_errors: + raise utils.build_validation_error(title=self._model_name, errors_map=item_errors) return result or None diff --git a/pydantic_xml/serializers/factories/mapping.py b/pydantic_xml/serializers/factories/mapping.py index d42a97d..f080265 100644 --- a/pydantic_xml/serializers/factories/mapping.py +++ b/pydantic_xml/serializers/factories/mapping.py @@ -5,7 +5,7 @@ from pydantic_xml import errors from pydantic_xml.element import XmlElementReader, XmlElementWriter from pydantic_xml.serializers.serializer import TYPE_FAMILY, SchemaTypeFamily, SearchMode, Serializer -from pydantic_xml.typedefs import EntityLocation, NsMap +from pydantic_xml.typedefs import EntityLocation, Location, NsMap from pydantic_xml.utils import QName, merge_nsmaps, select_ns @@ -49,6 +49,8 @@ def deserialize( element: Optional[XmlElementReader], *, context: Optional[Dict[str, Any]], + sourcemap: Dict[Location, int], + loc: Location, ) -> Optional[Dict[str, str]]: if self._computed: return None @@ -115,12 +117,15 @@ def deserialize( element: Optional[XmlElementReader], *, context: Optional[Dict[str, Any]], + sourcemap: Dict[Location, int], + loc: Location, ) -> Optional[Dict[str, str]]: if self._computed: return None if element and (sub_element := element.pop_element(self._element_name, self._search_mode)) is not None: - return super().deserialize(sub_element, context=context) + sourcemap[loc] = sub_element.get_sourceline() + return super().deserialize(sub_element, context=context, sourcemap=sourcemap, loc=loc) else: return None diff --git a/pydantic_xml/serializers/factories/model.py b/pydantic_xml/serializers/factories/model.py index fc8d1e9..354838c 100644 --- a/pydantic_xml/serializers/factories/model.py +++ b/pydantic_xml/serializers/factories/model.py @@ -1,16 +1,16 @@ import abc import typing -from typing import Any, Dict, List, Mapping, Optional, Set, Type +from typing import Any, Dict, List, Mapping, Optional, Set, Type, Union import pydantic as pd import pydantic_core as pdc from pydantic_core import core_schema as pcs import pydantic_xml as pxml -from pydantic_xml import errors +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.typedefs import EntityLocation, NsMap +from pydantic_xml.typedefs import EntityLocation, Location, NsMap from pydantic_xml.utils import QName, merge_nsmaps, select_ns @@ -31,11 +31,18 @@ def nsmap(self) -> Optional[NsMap]: ... def _check_extra(cls, error_title: str, element: XmlElementReader) -> None: line_errors: List[pdc.InitErrorDetails] = [] - for path, value in element.get_unbound(): + for path, attr, value in element.get_unbound(): line_errors.append( pdc.InitErrorDetails( - type='extra_forbidden', - loc=path, + type=pdc.PydanticCustomError( + 'extra_forbidden', + "[line {sourceline}]: {orig}", + { + 'sourceline': (path or (element,))[-1].get_sourceline(), + 'orig': "Extra inputs are not permitted", + }, + ), + loc=tuple(el.tag for el in path) + ((f"@{attr}",) if attr else ()), input=value, ), ) @@ -171,20 +178,35 @@ def deserialize( element: Optional[XmlElementReader], *, context: Optional[Dict[str, Any]], + sourcemap: Dict[Location, int], + loc: Location, ) -> Optional['pxml.BaseXmlModel']: if element is None: return None - result = { - self._fields_validation_aliases.get(field_name, field_name): field_value - for field_name, field_serializer in self._field_serializers.items() - if (field_value := field_serializer.deserialize(element, context=context)) is not None - } + result: Dict[str, Any] = {} + field_errors: Dict[Union[None, str, int], pd.ValidationError] = {} + for field_name, field_serializer in self._field_serializers.items(): + try: + loc = (field_name,) + sourcemap[loc] = element.get_sourceline() + 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 + except pd.ValidationError as err: + field_errors[field_name] = err + + if field_errors: + raise utils.build_validation_error(title=self._model.__name__, errors_map=field_errors) if self._model.model_config.get('extra', 'ignore') == 'forbid': self._check_extra(self._model.__name__, element) - return self._model.model_validate(result, strict=False, context=context) + try: + return self._model.model_validate(result, strict=False, context=context) + except pd.ValidationError as err: + raise utils.set_validation_error_sourceline(err, sourcemap) class RootModelSerializer(BaseModelSerializer): @@ -263,17 +285,26 @@ def deserialize( element: Optional[XmlElementReader], *, context: Optional[Dict[str, Any]], + sourcemap: Dict[Location, int], + loc: Location, ) -> Optional['pxml.BaseXmlModel']: if element is None: return None - if (result := self._root_serializer.deserialize(element, context=context)) is None: - result = pdc.PydanticUndefined + try: + result = self._root_serializer.deserialize(element, context=context, sourcemap=sourcemap, loc=loc) + if result is None: + result = pdc.PydanticUndefined + except pd.ValidationError as err: + raise utils.build_validation_error(title=self._model.__name__, errors_map={None: err}) if self._model.model_config.get('extra', 'ignore') == 'forbid': self._check_extra(self._model.__name__, element) - return self._model.model_validate(result, strict=False, context=context) + try: + return self._model.model_validate(result, strict=False, context=context) + except pd.ValidationError as err: + raise utils.set_validation_error_sourceline(err, sourcemap) class ModelProxySerializer(BaseModelSerializer): @@ -356,18 +387,25 @@ def deserialize( element: Optional[XmlElementReader], *, context: Optional[Dict[str, Any]], + sourcemap: Dict[Location, int], + loc: Location, ) -> Optional['pxml.BaseXmlModel']: assert self._model.__xml_serializer__ is not None, f"model {self._model.__name__} is partially initialized" if self._computed: return None - if element is not None and \ - (sub_element := element.pop_element(self._element_name, self._search_mode)) is not None: + if element is None: + return None + + if (sub_element := element.pop_element(self._element_name, self._search_mode)) is not None: + sourcemap[loc] = sub_element.get_sourceline() if is_element_nill(sub_element): return None else: - return self._model.__xml_serializer__.deserialize(sub_element, context=context) + return self._model.__xml_serializer__.deserialize( + sub_element, context=context, sourcemap=sourcemap, loc=loc, + ) else: return None diff --git a/pydantic_xml/serializers/factories/primitive.py b/pydantic_xml/serializers/factories/primitive.py index addfcff..52da225 100644 --- a/pydantic_xml/serializers/factories/primitive.py +++ b/pydantic_xml/serializers/factories/primitive.py @@ -5,7 +5,7 @@ from pydantic_xml import errors from pydantic_xml.element import XmlElementReader, XmlElementWriter, is_element_nill, make_element_nill from pydantic_xml.serializers.serializer import SearchMode, Serializer, encode_primitive -from pydantic_xml.typedefs import EntityLocation, NsMap +from pydantic_xml.typedefs import EntityLocation, Location, NsMap from pydantic_xml.utils import QName, merge_nsmaps, select_ns PrimitiveTypeSchema = Union[ @@ -57,6 +57,8 @@ def deserialize( element: Optional[XmlElementReader], *, context: Optional[Dict[str, Any]], + sourcemap: Dict[Location, int], + loc: Location, ) -> Optional[str]: if self._computed: return None @@ -113,6 +115,8 @@ def deserialize( element: Optional[XmlElementReader], *, context: Optional[Dict[str, Any]], + sourcemap: Dict[Location, int], + loc: Location, ) -> Optional[str]: if self._computed: return None @@ -172,13 +176,18 @@ def deserialize( element: Optional[XmlElementReader], *, context: Optional[Dict[str, Any]], + sourcemap: Dict[Location, int], + loc: Location, ) -> Optional[str]: if self._computed: return None - if element is not None and \ - (sub_element := element.pop_element(self._element_name, self._search_mode)) is not None: - return super().deserialize(sub_element, context=context) + if element is None: + return None + + if (sub_element := element.pop_element(self._element_name, self._search_mode)) is not None: + sourcemap[loc] = sub_element.get_sourceline() + return super().deserialize(sub_element, context=context, sourcemap=sourcemap, loc=loc) else: return None diff --git a/pydantic_xml/serializers/factories/raw.py b/pydantic_xml/serializers/factories/raw.py index 37a86f5..cf37d89 100644 --- a/pydantic_xml/serializers/factories/raw.py +++ b/pydantic_xml/serializers/factories/raw.py @@ -5,7 +5,7 @@ from pydantic_xml import errors from pydantic_xml.element import XmlElementReader, XmlElementWriter from pydantic_xml.serializers.serializer import SearchMode, Serializer -from pydantic_xml.typedefs import EntityLocation, NsMap +from pydantic_xml.typedefs import EntityLocation, Location, NsMap from pydantic_xml.utils import QName, merge_nsmaps, select_ns @@ -47,12 +47,17 @@ def deserialize( element: Optional[XmlElementReader], *, context: Optional[Dict[str, Any]], + sourcemap: Dict[Location, int], + loc: Location, ) -> Optional[str]: if self._computed: return None - if element is not None and \ - (sub_element := element.pop_element(self._element_name, self._search_mode)) is not None: + if element is None: + return None + + if (sub_element := element.pop_element(self._element_name, self._search_mode)) is not None: + sourcemap[loc] = sub_element.get_sourceline() return sub_element.to_native() else: return None diff --git a/pydantic_xml/serializers/factories/tagged_union.py b/pydantic_xml/serializers/factories/tagged_union.py index ef438e2..963cb28 100644 --- a/pydantic_xml/serializers/factories/tagged_union.py +++ b/pydantic_xml/serializers/factories/tagged_union.py @@ -10,6 +10,7 @@ from pydantic_xml.serializers.factories.model import ModelProxySerializer from pydantic_xml.serializers.factories.primitive import AttributeSerializer from pydantic_xml.serializers.serializer import TYPE_FAMILY, SchemaTypeFamily, SearchMode, Serializer +from pydantic_xml.typedefs import Location class ModelSerializer(Serializer): @@ -83,6 +84,8 @@ def deserialize( element: Optional[XmlElementReader], *, context: Optional[Dict[str, Any]], + sourcemap: Dict[Location, int], + loc: Location, ) -> Optional['pxml.BaseXmlModel']: if self._computed: return None @@ -98,7 +101,8 @@ def deserialize( step_forward=False, ) if sub_element is not None and sub_element.get_attrib(self._discriminating_attr_name) == tag: - return serializer.deserialize(element, context=context) + sourcemap[loc] = sub_element.get_sourceline() + return serializer.deserialize(element, context=context, sourcemap=sourcemap, loc=loc) return None diff --git a/pydantic_xml/serializers/factories/union.py b/pydantic_xml/serializers/factories/union.py index 6876db4..d1e4136 100644 --- a/pydantic_xml/serializers/factories/union.py +++ b/pydantic_xml/serializers/factories/union.py @@ -8,6 +8,7 @@ from pydantic_xml.element import XmlElementReader, XmlElementWriter from pydantic_xml.serializers.factories.model import ModelProxySerializer from pydantic_xml.serializers.serializer import TYPE_FAMILY, SchemaTypeFamily, Serializer +from pydantic_xml.typedefs import Location class PrimitiveTypeSerializer(Serializer): @@ -43,11 +44,13 @@ def deserialize( element: Optional[XmlElementReader], *, context: Optional[Dict[str, Any]], + sourcemap: Dict[Location, int], + loc: Location, ) -> Optional[str]: if self._computed: return None - return self._inner_serializer.deserialize(element, context=context) + return self._inner_serializer.deserialize(element, context=context, sourcemap=sourcemap, loc=loc) class ModelSerializer(Serializer): @@ -91,6 +94,8 @@ def deserialize( element: Optional[XmlElementReader], *, context: Optional[Dict[str, Any]], + sourcemap: Dict[Location, int], + loc: Location, ) -> Optional['pxml.BaseXmlModel']: if self._computed: return None @@ -103,7 +108,7 @@ def deserialize( for serializer in self._inner_serializers: snapshot = element.create_snapshot() try: - if (result := serializer.deserialize(snapshot, context=context)) is None: + if (result := serializer.deserialize(snapshot, context=context, sourcemap=sourcemap, loc=loc)) is None: continue else: element.apply_snapshot(snapshot) diff --git a/pydantic_xml/serializers/factories/wrapper.py b/pydantic_xml/serializers/factories/wrapper.py index 217e76b..e447405 100644 --- a/pydantic_xml/serializers/factories/wrapper.py +++ b/pydantic_xml/serializers/factories/wrapper.py @@ -4,7 +4,7 @@ from pydantic_xml.element import XmlElementReader, XmlElementWriter from pydantic_xml.serializers.serializer import SearchMode, Serializer -from pydantic_xml.typedefs import NsMap +from pydantic_xml.typedefs import Location, NsMap from pydantic_xml.utils import QName, merge_nsmaps, select_ns @@ -60,13 +60,22 @@ def deserialize( element: Optional[XmlElementReader], *, context: Optional[Dict[str, Any]], + sourcemap: Dict[Location, int], + loc: Location, ) -> Optional[Any]: if self._computed: return None - if element is not None and \ - (sub_element := element.find_sub_element(self._path, self._search_mode)) is not None: - return self._inner_serializer.deserialize(sub_element, context=context) + if element is None: + return None + + if sub_elements := element.find_sub_element(self._path, self._search_mode): + sub_element = sub_elements[-1] + if len(sub_elements) == len(self._path): + sourcemap[loc] = sub_element.get_sourceline() + return self._inner_serializer.deserialize(sub_element, context=context, sourcemap=sourcemap, loc=loc) + else: + return None else: return None diff --git a/pydantic_xml/serializers/serializer.py b/pydantic_xml/serializers/serializer.py index 7932651..1c379c6 100644 --- a/pydantic_xml/serializers/serializer.py +++ b/pydantic_xml/serializers/serializer.py @@ -10,7 +10,7 @@ from pydantic_xml.element import SearchMode, XmlElementReader, XmlElementWriter from pydantic_xml.errors import ModelError -from pydantic_xml.typedefs import EntityLocation, NsMap +from pydantic_xml.typedefs import EntityLocation, Location, NsMap from pydantic_xml.utils import select_ns from . import factories @@ -289,11 +289,15 @@ def deserialize( element: Optional[XmlElementReader], *, context: Optional[Dict[str, Any]], + sourcemap: Dict[Location, int], + loc: Location, ) -> Optional[Any]: """ Deserializes a value from the xml element. :param element: xml element the value is deserialized from :param context: pydantic validation context + :param sourcemap: source-to-element mapping + :param loc: entity location :return: deserialized value """ diff --git a/pydantic_xml/typedefs.py b/pydantic_xml/typedefs.py index b5d151d..d2fdf02 100644 --- a/pydantic_xml/typedefs.py +++ b/pydantic_xml/typedefs.py @@ -1,6 +1,7 @@ from enum import IntEnum -from typing import Dict +from typing import Dict, Tuple, Union +Location = Tuple[Union[str, int], ...] NsMap = Dict[str, str] diff --git a/pydantic_xml/utils.py b/pydantic_xml/utils.py index a001470..9917db0 100644 --- a/pydantic_xml/utils.py +++ b/pydantic_xml/utils.py @@ -2,10 +2,13 @@ import itertools as it import re from collections import ChainMap -from typing import Iterable, Optional, cast +from typing import Dict, Iterable, List, Mapping, Optional, Union, cast + +import pydantic as pd +import pydantic_core as pdc from .element.native import etree -from .typedefs import NsMap +from .typedefs import Location, NsMap @dc.dataclass(frozen=True) @@ -92,3 +95,50 @@ def select_ns(*nss: Optional[str]) -> Optional[str]: return ns return None + + +def build_validation_error( + title: str, + errors_map: Mapping[Union[None, str, int], pd.ValidationError], +) -> pd.ValidationError: + line_errors: List[pdc.InitErrorDetails] = [] + for location, validation_error in errors_map.items(): + for error in validation_error.errors(): + line_errors.append( + pdc.InitErrorDetails( + type=pdc.PydanticCustomError(error['type'], error['msg'], error.get('ctx')), + loc=(location, *error['loc']) if location is not None else error['loc'], + input=error['input'], + ), + ) + + return pd.ValidationError.from_exception_data( + title=title, + input_type='json', + line_errors=line_errors, + ) + + +def set_validation_error_sourceline(err: pd.ValidationError, sourcemap: Dict[Location, int]) -> pd.ValidationError: + line_errors: List[pdc.InitErrorDetails] = [] + for error in err.errors(): + loc, sourceline = error['loc'], -1 + while loc and (sourceline := sourcemap.get(loc, sourceline)) == -1: + loc = tuple(loc[:-1]) + + line_errors.append( + pdc.InitErrorDetails( + type=pdc.PydanticCustomError( + error['type'], + "[line {sourceline}]: {orig}", + {'sourceline': sourceline, 'orig': error['msg']}, + ), + loc=error['loc'], + input=error['input'], + ), + ) + + return pd.ValidationError.from_exception_data( + err.title, + line_errors=line_errors, + ) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..b9ca491 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +filterwarnings = + ignore::DeprecationWarning + default:::pydantic_xml diff --git a/tests/helpers.py b/tests/helpers.py index e99cccd..4eb9298 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -39,3 +39,10 @@ def is_lxml_native() -> bool: return False return native.etree is lxml.etree + + +def fmt_sourceline(linenum: int) -> int: + if is_lxml_native(): + return linenum + else: + return -1 diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..a72fdc3 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,172 @@ +from typing import List, Tuple, Union + +import pydantic as pd +import pytest + +from pydantic_xml import BaseXmlModel, attr, element, wrapped +from tests.helpers import fmt_sourceline + + +def test_submodel_errors(): + class TestSubModel(BaseXmlModel, tag='submodel'): + field1: int = element() + field2: int = element() + field3: int = element() + + class TestModel(BaseXmlModel, tag='model'): + submodel: TestSubModel + + xml = ''' + + + a + 1 + b + + + ''' + + with pytest.raises(pd.ValidationError) as exc: + TestModel.from_xml(xml) + + err = exc.value + assert err.title == 'TestModel' + assert err.error_count() == 2 + assert err.errors() == [ + { + 'input': 'a', + 'loc': ('submodel', 'field1'), + 'msg': f'[line {fmt_sourceline(4)}]: Input should be a valid integer, unable to parse string as an integer', + 'type': 'int_parsing', + 'ctx': { + 'orig': 'Input should be a valid integer, unable to parse string as an integer', + 'sourceline': fmt_sourceline(4), + }, + }, + { + 'input': 'b', + 'loc': ('submodel', 'field3'), + 'msg': f'[line {fmt_sourceline(6)}]: Input should be a valid integer, unable to parse string as an integer', + 'type': 'int_parsing', + 'ctx': { + 'orig': 'Input should be a valid integer, unable to parse string as an integer', + 'sourceline': fmt_sourceline(6), + }, + }, + ] + + +def test_homogeneous_collection_errors(): + class TestSubModel(BaseXmlModel, tag='submodel'): + attr1: int = attr() + + class TestModel(BaseXmlModel, tag='model'): + submodel: List[TestSubModel] + + xml = ''' + + + + + + ''' + + with pytest.raises(pd.ValidationError) as exc: + TestModel.from_xml(xml) + + err = exc.value + assert err.title == 'TestModel' + assert err.error_count() == 2 + assert err.errors() == [ + { + 'input': 'a', + 'loc': ('submodel', 0, 'attr1'), + 'msg': f'[line {fmt_sourceline(3)}]: Input should be a valid integer, unable to parse string as an integer', + 'type': 'int_parsing', + 'ctx': { + 'orig': 'Input should be a valid integer, unable to parse string as an integer', + 'sourceline': fmt_sourceline(3), + }, + }, + { + 'input': 'b', + 'loc': ('submodel', 2, 'attr1'), + 'msg': f'[line {fmt_sourceline(5)}]: Input should be a valid integer, unable to parse string as an integer', + 'type': 'int_parsing', + 'ctx': { + 'orig': 'Input should be a valid integer, unable to parse string as an integer', + 'sourceline': fmt_sourceline(5), + }, + }, + ] + + +def test_heterogeneous_collection_errors(): + class TestSubModel(BaseXmlModel, tag='submodel'): + attrs: Union[int, bool] = wrapped('wrapper') + + class TestModel(BaseXmlModel, tag='model'): + submodel: Tuple[TestSubModel, TestSubModel, TestSubModel] + + xml = ''' + + + a + + + 1 + + + b + + + ''' + + with pytest.raises(pd.ValidationError) as exc: + TestModel.from_xml(xml) + + err = exc.value + assert err.title == 'TestModel' + assert err.error_count() == 4 + assert err.errors() == [ + { + 'input': 'a', + 'loc': ('submodel', 0, 'attrs', 'int'), + 'msg': f'[line {fmt_sourceline(4)}]: Input should be a valid integer, unable to parse string as an integer', + 'type': 'int_parsing', + 'ctx': { + 'orig': 'Input should be a valid integer, unable to parse string as an integer', + 'sourceline': fmt_sourceline(4), + }, + }, + { + 'input': 'a', + 'loc': ('submodel', 0, 'attrs', 'bool'), + 'msg': f'[line {fmt_sourceline(4)}]: Input should be a valid boolean, unable to interpret input', + 'type': 'bool_parsing', + 'ctx': { + 'orig': 'Input should be a valid boolean, unable to interpret input', + 'sourceline': fmt_sourceline(4), + }, + }, + { + 'input': 'b', + 'loc': ('submodel', 2, 'attrs', 'int'), + 'msg': f'[line {fmt_sourceline(10)}]: Input should be a valid integer, unable to parse string as an integer', + 'type': 'int_parsing', + 'ctx': { + 'orig': 'Input should be a valid integer, unable to parse string as an integer', + 'sourceline': fmt_sourceline(10), + }, + }, + { + 'input': 'b', + 'loc': ('submodel', 2, 'attrs', 'bool'), + 'msg': f'[line {fmt_sourceline(10)}]: Input should be a valid boolean, unable to interpret input', + 'type': 'bool_parsing', + 'ctx': { + 'orig': 'Input should be a valid boolean, unable to interpret input', + 'sourceline': fmt_sourceline(10), + }, + }, + ] diff --git a/tests/test_examples.py b/tests/test_examples.py index 4b95387..2422de6 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -3,6 +3,7 @@ from pathlib import Path import pytest +from helpers import is_lxml_native MODULE_PATH = Path(__file__).parent PROJECT_ROOT = MODULE_PATH.parent @@ -22,6 +23,13 @@ def test_snippets_py39(snippet: Path): loader.load_module('snippet') +@pytest.mark.skipif(not is_lxml_native(), reason='not lxml used') +@pytest.mark.parametrize('snippet', list((EXAMPLES_PATH / 'snippets' / 'lxml').glob('*.py')), ids=lambda p: p.name) +def test_snippets_py39(snippet: Path): + loader = importlib.machinery.SourceFileLoader('snippet', str(snippet)) + loader.load_module('snippet') + + @pytest.fixture( params=[ 'custom-encoder', diff --git a/tests/test_extra.py b/tests/test_extra.py index 303db39..bcb9e2f 100644 --- a/tests/test_extra.py +++ b/tests/test_extra.py @@ -1,10 +1,10 @@ from typing import Dict -from unittest.mock import ANY import pydantic as pd import pytest from pydantic_xml import BaseXmlModel, attr, element, wrapped +from tests.helpers import fmt_sourceline @pytest.mark.parametrize('search_mode', ['strict', 'ordered', 'unordered']) @@ -30,23 +30,32 @@ class TestModel(BaseXmlModel, tag='model', extra='forbid', search_mode=search_mo { 'input': 'text value', 'loc': (), - 'msg': 'Extra inputs are not permitted', + 'msg': f'[line {fmt_sourceline(2)}]: Extra inputs are not permitted', 'type': 'extra_forbidden', - 'url': ANY, + 'ctx': { + 'orig': 'Extra inputs are not permitted', + 'sourceline': fmt_sourceline(2), + }, }, { 'input': 'attr value 2', 'loc': ('@attr2',), - 'msg': 'Extra inputs are not permitted', + 'msg': f'[line {fmt_sourceline(2)}]: Extra inputs are not permitted', 'type': 'extra_forbidden', - 'url': ANY, + 'ctx': { + 'orig': 'Extra inputs are not permitted', + 'sourceline': fmt_sourceline(2), + }, }, { 'input': 'field value 2', 'loc': ('field2',), - 'msg': 'Extra inputs are not permitted', + 'msg': f'[line {fmt_sourceline(4)}]: Extra inputs are not permitted', 'type': 'extra_forbidden', - 'url': ANY, + 'ctx': { + 'orig': 'Extra inputs are not permitted', + 'sourceline': fmt_sourceline(4), + }, }, ] @@ -76,16 +85,22 @@ class TestModel(BaseXmlModel, tag='model', extra='forbid', search_mode=search_mo { 'input': 'text value', 'loc': ('element1',), - 'msg': 'Extra inputs are not permitted', + 'msg': f'[line {fmt_sourceline(3)}]: Extra inputs are not permitted', 'type': 'extra_forbidden', - 'url': ANY, + 'ctx': { + 'orig': 'Extra inputs are not permitted', + 'sourceline': fmt_sourceline(3), + }, }, { 'input': 'text value', 'loc': ('element2', 'subelement'), - 'msg': 'Extra inputs are not permitted', + 'msg': f'[line {fmt_sourceline(5)}]: Extra inputs are not permitted', 'type': 'extra_forbidden', - 'url': ANY, + 'ctx': { + 'orig': 'Extra inputs are not permitted', + 'sourceline': fmt_sourceline(5), + }, }, ] @@ -112,29 +127,38 @@ class TestModel(BaseXmlModel, tag='model', extra='forbid', search_mode=search_mo TestModel.from_xml(xml) err = exc.value - assert err.title == 'TestSubModel' + assert err.title == 'TestModel' assert err.error_count() == 3 assert err.errors() == [ { 'input': 'text value', - 'loc': (), - 'msg': 'Extra inputs are not permitted', + 'loc': ('submodel',), + 'msg': f'[line {fmt_sourceline(3)}]: Extra inputs are not permitted', 'type': 'extra_forbidden', - 'url': ANY, + 'ctx': { + 'orig': 'Extra inputs are not permitted', + 'sourceline': fmt_sourceline(3), + }, }, { 'input': 'attr value 2', - 'loc': ('@attr2',), - 'msg': 'Extra inputs are not permitted', + 'loc': ('submodel', '@attr2'), + 'msg': f'[line {fmt_sourceline(3)}]: Extra inputs are not permitted', 'type': 'extra_forbidden', - 'url': ANY, + 'ctx': { + 'orig': 'Extra inputs are not permitted', + 'sourceline': fmt_sourceline(3), + }, }, { 'input': 'field value 2', - 'loc': ('field2',), - 'msg': 'Extra inputs are not permitted', + 'loc': ('submodel', 'field2'), + 'msg': f'[line {fmt_sourceline(5)}]: Extra inputs are not permitted', 'type': 'extra_forbidden', - 'url': ANY, + 'ctx': { + 'orig': 'Extra inputs are not permitted', + 'sourceline': fmt_sourceline(5), + }, }, ] @@ -168,29 +192,41 @@ class TestModel(BaseXmlModel, tag='model', extra='forbid', search_mode=search_mo { 'input': 'text value', 'loc': ('wrapper1',), - 'msg': 'Extra inputs are not permitted', + 'msg': f'[line {fmt_sourceline(3)}]: Extra inputs are not permitted', 'type': 'extra_forbidden', - 'url': ANY, + 'ctx': { + 'orig': 'Extra inputs are not permitted', + 'sourceline': fmt_sourceline(3), + }, }, { 'input': 'field value 2', 'loc': ('wrapper1', 'field2'), - 'msg': 'Extra inputs are not permitted', + 'msg': f'[line {fmt_sourceline(5)}]: Extra inputs are not permitted', 'type': 'extra_forbidden', - 'url': ANY, + 'ctx': { + 'orig': 'Extra inputs are not permitted', + 'sourceline': fmt_sourceline(5), + }, }, { 'input': 'attr value 1', 'loc': ('wrapper2', '@attr1'), - 'msg': 'Extra inputs are not permitted', + 'msg': f'[line {fmt_sourceline(7)}]: Extra inputs are not permitted', 'type': 'extra_forbidden', - 'url': ANY, + 'ctx': { + 'orig': 'Extra inputs are not permitted', + 'sourceline': fmt_sourceline(7), + }, }, { 'input': 'field value 2', 'loc': ('wrapper2', 'field2'), - 'msg': 'Extra inputs are not permitted', + 'msg': f'[line {fmt_sourceline(9)}]: Extra inputs are not permitted', 'type': 'extra_forbidden', - 'url': ANY, + 'ctx': { + 'orig': 'Extra inputs are not permitted', + 'sourceline': fmt_sourceline(9), + }, }, ] diff --git a/tests/test_search_modes.py b/tests/test_search_modes.py index c82b4ce..0c2d2e1 100644 --- a/tests/test_search_modes.py +++ b/tests/test_search_modes.py @@ -6,6 +6,7 @@ from helpers import assert_xml_equal from pydantic_xml import BaseXmlModel, attr, element, wrapped +from tests.helpers import fmt_sourceline def test_optional_field(): @@ -127,10 +128,13 @@ class TestModel(BaseXmlModel, tag='model', search_mode='ordered'): assert len(errors) == 1 assert errors[0] == { 'loc': ('element3',), - 'msg': 'Field required', + 'msg': f'[line {fmt_sourceline(2)}]: Field required', + 'ctx': { + 'orig': 'Field required', + 'sourceline': fmt_sourceline(2), + }, 'type': 'missing', 'input': ANY, - 'url': ANY, } @@ -197,10 +201,13 @@ class TestModel(BaseXmlModel, tag='model', search_mode='unordered'): assert len(errors) == 1 assert errors[0] == { 'loc': ('element3',), - 'msg': 'Field required', + 'msg': f'[line {fmt_sourceline(2)}]: Field required', + 'ctx': { + 'orig': 'Field required', + 'sourceline': fmt_sourceline(2), + }, 'type': 'missing', 'input': ANY, - 'url': ANY, } @@ -437,10 +444,13 @@ class TestModel(BaseXmlModel, tag='model'): assert len(errors) == 1 assert errors[0] == { 'loc': ('element3',), - 'msg': 'Field required', + 'msg': f'[line {fmt_sourceline(2)}]: Field required', + 'ctx': { + 'orig': 'Field required', + 'sourceline': fmt_sourceline(2), + }, 'type': 'missing', 'input': ANY, - 'url': ANY, } @@ -517,10 +527,13 @@ class TestModel(BaseXmlModel, tag='model', search_mode='ordered'): assert len(errors) == 1 assert errors[0] == { 'loc': ('element3',), - 'msg': 'Field required', + 'msg': f'[line {fmt_sourceline(2)}]: Field required', + 'ctx': { + 'orig': 'Field required', + 'sourceline': fmt_sourceline(2), + }, 'type': 'missing', 'input': ANY, - 'url': ANY, } @@ -600,8 +613,11 @@ class TestModel(BaseXmlModel, tag='model', search_mode='unordered'): assert len(errors) == 1 assert errors[0] == { 'loc': ('element3',), - 'msg': 'Field required', + 'msg': f'[line {fmt_sourceline(2)}]: Field required', + 'ctx': { + 'orig': 'Field required', + 'sourceline': fmt_sourceline(2), + }, 'type': 'missing', 'input': ANY, - 'url': ANY, }