Skip to content

Commit d785fb2

Browse files
committed
document line number added to validation errors.
1 parent 3dc1553 commit d785fb2

File tree

24 files changed

+341
-95
lines changed

24 files changed

+341
-95
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: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pydantic_xml import errors, utils
77
from pydantic_xml.element import XmlElementReader, XmlElementWriter
88
from pydantic_xml.serializers.serializer import TYPE_FAMILY, SchemaTypeFamily, Serializer
9-
from pydantic_xml.typedefs import EntityLocation
9+
from pydantic_xml.typedefs import EntityLocation, Location
1010

1111

1212
class ElementSerializer(Serializer):
@@ -47,6 +47,8 @@ def deserialize(
4747
element: Optional[XmlElementReader],
4848
*,
4949
context: Optional[Dict[str, Any]],
50+
sourcemap: Dict[Location, int],
51+
loc: Location,
5052
) -> Optional[List[Any]]:
5153
if self._computed:
5254
return None
@@ -58,7 +60,7 @@ def deserialize(
5860
item_errors: Dict[Union[None, str, int], pd.ValidationError] = {}
5961
for idx, serializer in enumerate(self._inner_serializers):
6062
try:
61-
result.append(serializer.deserialize(element, context=context))
63+
result.append(serializer.deserialize(element, context=context, sourcemap=sourcemap, loc=loc + (idx,)))
6264
except pd.ValidationError as err:
6365
item_errors[idx] = err
6466

pydantic_xml/serializers/factories/homogeneous.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pydantic_xml import errors, utils
88
from pydantic_xml.element import XmlElementReader, XmlElementWriter
99
from pydantic_xml.serializers.serializer import TYPE_FAMILY, SchemaTypeFamily, Serializer
10-
from pydantic_xml.typedefs import EntityLocation
10+
from pydantic_xml.typedefs import EntityLocation, Location
1111

1212
HomogeneousCollectionTypeSchema = Union[
1313
pcs.TupleVariableSchema,
@@ -53,18 +53,22 @@ def deserialize(
5353
element: Optional[XmlElementReader],
5454
*,
5555
context: Optional[Dict[str, Any]],
56+
sourcemap: Dict[Location, int],
57+
loc: Location,
5658
) -> Optional[List[Any]]:
5759
if self._computed:
5860
return None
5961

6062
if element is None:
6163
return None
6264

65+
serializer = self._inner_serializer
6366
result: List[Any] = []
6467
item_errors: Dict[Union[None, str, int], pd.ValidationError] = {}
6568
for idx in it.count():
6669
try:
67-
if (value := self._inner_serializer.deserialize(element, context=context)) is None:
70+
value = serializer.deserialize(element, context=context, sourcemap=sourcemap, loc=loc + (idx,))
71+
if value is None:
6872
break
6973
except pd.ValidationError as err:
7074
item_errors[idx] = err

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)