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,
}