Skip to content

Commit 7647e8a

Browse files
authored
Merge pull request #150 from dapper91/errors-improve
- validation errors provide the full path to the malformed field (including nested sub-models). - error text contain the xml document source line where the error occurred (lxml parser only).
2 parents f38d18f + d785fb2 commit 7647e8a

File tree

24 files changed

+554
-114
lines changed

24 files changed

+554
-114
lines changed

docs/source/pages/data-binding/elements.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ choice because it works in predictable time since it doesn't require any look-ah
206206

207207
.. grid-item-card:: Model
208208

209-
.. literalinclude:: ../../../../examples/snippets/model_mode_strict.py
209+
.. literalinclude:: ../../../../examples/snippets/lxml/model_mode_strict.py
210210
:language: python
211211
:start-after: model-start
212212
:end-before: model-end
@@ -220,15 +220,15 @@ choice because it works in predictable time since it doesn't require any look-ah
220220

221221
.. tab-item:: XML
222222

223-
.. literalinclude:: ../../../../examples/snippets/model_mode_strict.py
223+
.. literalinclude:: ../../../../examples/snippets/lxml/model_mode_strict.py
224224
:language: xml
225225
:lines: 2-
226226
:start-after: xml-start
227227
:end-before: xml-end
228228

229229
.. tab-item:: JSON
230230

231-
.. literalinclude:: ../../../../examples/snippets/model_mode_strict.py
231+
.. literalinclude:: ../../../../examples/snippets/lxml/model_mode_strict.py
232232
:language: json
233233
:lines: 2-
234234
:start-after: json-start

examples/snippets/model_mode_strict.py renamed to examples/snippets/lxml/model_mode_strict.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,10 @@ class Company(
3535
error = e.errors()[0]
3636
assert error == {
3737
'loc': ('founded',),
38-
'msg': 'Field required',
38+
'msg': '[line 2]: Field required',
39+
'ctx': {'orig': 'Field required', 'sourceline': 2},
3940
'type': 'missing',
4041
'input': ANY,
41-
'url': ANY,
4242
}
4343
else:
4444
raise AssertionError('exception not raised')

pydantic_xml/element/element.py

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
from pydantic_xml.typedefs import NsMap
66

7+
PathElementT = TypeVar('PathElementT')
8+
PathT = Tuple[PathElementT, ...]
9+
710

811
class XmlElementReader(abc.ABC):
912
"""
@@ -90,7 +93,7 @@ def pop_element(self, tag: str, search_mode: 'SearchMode') -> Optional['XmlEleme
9093
"""
9194

9295
@abc.abstractmethod
93-
def find_sub_element(self, path: Sequence[str], search_mode: 'SearchMode') -> Optional['XmlElementReader']:
96+
def find_sub_element(self, path: Sequence[str], search_mode: 'SearchMode') -> PathT['XmlElementReader']:
9497
"""
9598
Searches for an element at the provided path. If the element is not found returns `None`.
9699
@@ -122,13 +125,21 @@ def to_native(self) -> Any:
122125
"""
123126

124127
@abc.abstractmethod
125-
def get_unbound(self) -> List[Tuple[Tuple[str, ...], str]]:
128+
def get_unbound(self) -> List[Tuple[PathT['XmlElementReader'], Optional[str], str]]:
126129
"""
127130
Returns unbound entities.
128131
129132
:return: list of unbound entities
130133
"""
131134

135+
@abc.abstractmethod
136+
def get_sourceline(self) -> int:
137+
"""
138+
Returns source line of the element in the xml document.
139+
140+
:return: source line
141+
"""
142+
132143

133144
class XmlElementWriter(abc.ABC):
134145
"""
@@ -265,6 +276,7 @@ def __init__(
265276
attributes: Optional[Dict[str, str]] = None,
266277
elements: Optional[List['XmlElement[NativeElement]']] = None,
267278
nsmap: Optional[NsMap] = None,
279+
sourceline: int = -1,
268280
):
269281
self._tag = tag
270282
self._nsmap = nsmap
@@ -275,6 +287,11 @@ def __init__(
275287
elements=elements or [],
276288
next_element_idx=0,
277289
)
290+
self._sourceline = sourceline
291+
292+
@abc.abstractmethod
293+
def get_sourceline(self) -> int:
294+
return self._sourceline
278295

279296
@property
280297
def tag(self) -> str:
@@ -345,15 +362,17 @@ def pop_element(self, tag: str, search_mode: 'SearchMode') -> Optional['XmlEleme
345362

346363
return searcher(self._state, tag, False, True)
347364

348-
def find_sub_element(self, path: Sequence[str], search_mode: 'SearchMode') -> Optional['XmlElement[NativeElement]']:
365+
def find_sub_element(self, path: Sequence[str], search_mode: 'SearchMode') -> PathT['XmlElement[NativeElement]']:
349366
assert len(path) > 0, "path can't be empty"
350367

351-
root, path = path[0], path[1:]
352-
element = self.find_element(root, search_mode)
353-
if element and path:
354-
return element.find_sub_element(path, search_mode)
355-
356-
return element
368+
root, *path = path
369+
if (element := self.find_element(root, search_mode)) is not None:
370+
if path:
371+
return (element,) + element.find_sub_element(path, search_mode)
372+
else:
373+
return (element,)
374+
else:
375+
return ()
357376

358377
def find_element_or_create(
359378
self,
@@ -379,21 +398,24 @@ def find_element(
379398

380399
return searcher(self._state, tag, look_behind, step_forward)
381400

382-
def get_unbound(self, path: Tuple[str, ...] = ()) -> List[Tuple[Tuple[str, ...], str]]:
383-
result: List[Tuple[Tuple[str, ...], str]] = []
401+
def get_unbound(
402+
self,
403+
path: PathT[XmlElementReader] = (),
404+
) -> List[Tuple[PathT[XmlElementReader], Optional[str], str]]:
405+
result: List[Tuple[PathT[XmlElementReader], Optional[str], str]] = []
384406

385407
if self._state.text and (text := self._state.text.strip()):
386-
result.append((path, text))
408+
result.append((path, None, text))
387409

388410
if self._state.tail and (tail := self._state.tail.strip()):
389-
result.append((path, tail))
411+
result.append((path, None, tail))
390412

391413
if attrs := self._state.attrib:
392414
for name, value in attrs.items():
393-
result.append((path + (f'@{name}',), value))
415+
result.append((path, name, value))
394416

395417
for sub_element in self._state.elements:
396-
result.extend(sub_element.get_unbound(path + (sub_element.tag,)))
418+
result.extend(sub_element.get_unbound(path + (sub_element,)))
397419

398420
return result
399421

pydantic_xml/element/native/lxml.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import typing
12
from typing import Optional, Union
23

34
from lxml import etree
@@ -30,6 +31,7 @@ def from_native(cls, element: ElementT) -> 'XmlElement':
3031
for sub_element in element
3132
if not is_xml_comment(sub_element)
3233
],
34+
sourceline=typing.cast(int, element.sourceline) if element.sourceline is not None else -1,
3335
)
3436

3537
def to_native(self) -> ElementT:
@@ -48,6 +50,9 @@ def to_native(self) -> ElementT:
4850
def make_element(self, tag: str, nsmap: Optional[NsMap]) -> 'XmlElement':
4951
return XmlElement(tag, nsmap=nsmap)
5052

53+
def get_sourceline(self) -> int:
54+
return self._sourceline
55+
5156

5257
def force_str(val: Union[str, bytes]) -> str:
5358
if isinstance(val, bytes):

pydantic_xml/element/native/std.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ def to_native(self) -> ElementT:
3939
def make_element(self, tag: str, nsmap: Optional[NsMap]) -> 'XmlElement':
4040
return XmlElement(tag)
4141

42+
def get_sourceline(self) -> int:
43+
return -1
44+
4245

4346
def is_xml_comment(element: ElementT) -> bool:
4447
return element.tag is etree.Comment # type: ignore[comparison-overlap]

pydantic_xml/model.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,14 @@ def from_xml_tree(cls: Type[ModelT], root: etree.Element, context: Optional[Dict
340340
assert cls.__xml_serializer__ is not None, f"model {cls.__name__} is partially initialized"
341341

342342
if root.tag == cls.__xml_serializer__.element_name:
343-
obj = typing.cast(ModelT, cls.__xml_serializer__.deserialize(XmlElement.from_native(root), context=context))
343+
obj = typing.cast(
344+
ModelT, cls.__xml_serializer__.deserialize(
345+
XmlElement.from_native(root),
346+
context=context,
347+
sourcemap={},
348+
loc=(),
349+
),
350+
)
344351
return obj
345352
else:
346353
raise errors.ParsingError(

pydantic_xml/serializers/factories/heterogeneous.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,27 @@
1-
from typing import Any, Dict, List, Optional, Tuple
1+
from typing import Any, Dict, List, Optional, Tuple, Union
22

3+
import pydantic as pd
34
from pydantic_core import core_schema as pcs
45

5-
from pydantic_xml import errors
6+
from pydantic_xml import errors, utils
67
from pydantic_xml.element import XmlElementReader, XmlElementWriter
78
from pydantic_xml.serializers.serializer import TYPE_FAMILY, SchemaTypeFamily, Serializer
8-
from pydantic_xml.typedefs import EntityLocation
9+
from pydantic_xml.typedefs import EntityLocation, Location
910

1011

1112
class ElementSerializer(Serializer):
1213
@classmethod
1314
def from_core_schema(cls, schema: pcs.TuplePositionalSchema, ctx: Serializer.Context) -> 'ElementSerializer':
15+
model_name = ctx.model_name
1416
computed = ctx.field_computed
1517
inner_serializers: List[Serializer] = []
1618
for item_schema in schema['items_schema']:
1719
inner_serializers.append(Serializer.parse_core_schema(item_schema, ctx))
1820

19-
return cls(computed, tuple(inner_serializers))
21+
return cls(model_name, computed, tuple(inner_serializers))
2022

21-
def __init__(self, computed: bool, inner_serializers: Tuple[Serializer, ...]):
23+
def __init__(self, model_name: str, computed: bool, inner_serializers: Tuple[Serializer, ...]):
24+
self._model_name = model_name
2225
self._computed = computed
2326
self._inner_serializers = inner_serializers
2427

@@ -44,17 +47,26 @@ def deserialize(
4447
element: Optional[XmlElementReader],
4548
*,
4649
context: Optional[Dict[str, Any]],
50+
sourcemap: Dict[Location, int],
51+
loc: Location,
4752
) -> Optional[List[Any]]:
4853
if self._computed:
4954
return None
5055

5156
if element is None:
5257
return None
5358

54-
result = [
55-
serializer.deserialize(element, context=context)
56-
for serializer in self._inner_serializers
57-
]
59+
result: List[Any] = []
60+
item_errors: Dict[Union[None, str, int], pd.ValidationError] = {}
61+
for idx, serializer in enumerate(self._inner_serializers):
62+
try:
63+
result.append(serializer.deserialize(element, context=context, sourcemap=sourcemap, loc=loc + (idx,)))
64+
except pd.ValidationError as err:
65+
item_errors[idx] = err
66+
67+
if item_errors:
68+
raise utils.build_validation_error(title=self._model_name, errors_map=item_errors)
69+
5870
if all((value is None for value in result)):
5971
return None
6072
else:

pydantic_xml/serializers/factories/homogeneous.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1+
import itertools as it
12
from typing import Any, Dict, List, Optional, Union
23

4+
import pydantic as pd
35
from pydantic_core import core_schema as pcs
46

5-
from pydantic_xml import errors
7+
from pydantic_xml import errors, utils
68
from pydantic_xml.element import XmlElementReader, XmlElementWriter
79
from pydantic_xml.serializers.serializer import TYPE_FAMILY, SchemaTypeFamily, Serializer
8-
from pydantic_xml.typedefs import EntityLocation
10+
from pydantic_xml.typedefs import EntityLocation, Location
911

1012
HomogeneousCollectionTypeSchema = Union[
1113
pcs.TupleVariableSchema,
@@ -18,12 +20,14 @@
1820
class ElementSerializer(Serializer):
1921
@classmethod
2022
def from_core_schema(cls, schema: HomogeneousCollectionTypeSchema, ctx: Serializer.Context) -> 'ElementSerializer':
23+
model_name = ctx.model_name
2124
computed = ctx.field_computed
2225
inner_serializer = Serializer.parse_core_schema(schema['items_schema'], ctx)
2326

24-
return cls(computed, inner_serializer)
27+
return cls(model_name, computed, inner_serializer)
2528

26-
def __init__(self, computed: bool, inner_serializer: Serializer):
29+
def __init__(self, model_name: str, computed: bool, inner_serializer: Serializer):
30+
self._model_name = model_name
2731
self._computed = computed
2832
self._inner_serializer = inner_serializer
2933

@@ -49,16 +53,30 @@ def deserialize(
4953
element: Optional[XmlElementReader],
5054
*,
5155
context: Optional[Dict[str, Any]],
56+
sourcemap: Dict[Location, int],
57+
loc: Location,
5258
) -> Optional[List[Any]]:
5359
if self._computed:
5460
return None
5561

5662
if element is None:
5763
return None
5864

59-
result = []
60-
while (value := self._inner_serializer.deserialize(element, context=context)) is not None:
61-
result.append(value)
65+
serializer = self._inner_serializer
66+
result: List[Any] = []
67+
item_errors: Dict[Union[None, str, int], pd.ValidationError] = {}
68+
for idx in it.count():
69+
try:
70+
value = serializer.deserialize(element, context=context, sourcemap=sourcemap, loc=loc + (idx,))
71+
if value is None:
72+
break
73+
except pd.ValidationError as err:
74+
item_errors[idx] = err
75+
else:
76+
result.append(value)
77+
78+
if item_errors:
79+
raise utils.build_validation_error(title=self._model_name, errors_map=item_errors)
6280

6381
return result or None
6482

pydantic_xml/serializers/factories/mapping.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from pydantic_xml import errors
66
from pydantic_xml.element import XmlElementReader, XmlElementWriter
77
from pydantic_xml.serializers.serializer import TYPE_FAMILY, SchemaTypeFamily, SearchMode, Serializer
8-
from pydantic_xml.typedefs import EntityLocation, NsMap
8+
from pydantic_xml.typedefs import EntityLocation, Location, NsMap
99
from pydantic_xml.utils import QName, merge_nsmaps, select_ns
1010

1111

@@ -49,6 +49,8 @@ def deserialize(
4949
element: Optional[XmlElementReader],
5050
*,
5151
context: Optional[Dict[str, Any]],
52+
sourcemap: Dict[Location, int],
53+
loc: Location,
5254
) -> Optional[Dict[str, str]]:
5355
if self._computed:
5456
return None
@@ -115,12 +117,15 @@ def deserialize(
115117
element: Optional[XmlElementReader],
116118
*,
117119
context: Optional[Dict[str, Any]],
120+
sourcemap: Dict[Location, int],
121+
loc: Location,
118122
) -> Optional[Dict[str, str]]:
119123
if self._computed:
120124
return None
121125

122126
if element and (sub_element := element.pop_element(self._element_name, self._search_mode)) is not None:
123-
return super().deserialize(sub_element, context=context)
127+
sourcemap[loc] = sub_element.get_sourceline()
128+
return super().deserialize(sub_element, context=context, sourcemap=sourcemap, loc=loc)
124129
else:
125130
return None
126131

0 commit comments

Comments
 (0)