Skip to content

Commit 3611946

Browse files
authored
Merge pull request #107 from dapper91/dev
- pydantic extra='forbid' parameter is being applied to xml elements too.
2 parents 5e15e1a + 8aed816 commit 3611946

File tree

5 files changed

+141
-2
lines changed

5 files changed

+141
-2
lines changed

CHANGELOG.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
Changelog
22
=========
33

4+
2.2.0 (2023-09-07)
5+
------------------
6+
7+
- pydantic extra='forbid' parameter is being applied to xml elements too. See https://github.com/dapper91/pydantic-xml/pull/106.
8+
9+
10+
411
2.1.0 (2023-08-24)
512
------------------
613

pydantic_xml/element/element.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ class XmlElementReader(abc.ABC):
1111
Provides an interface for extracting element text, attributes and sub-elements.
1212
"""
1313

14+
@property
15+
@abc.abstractmethod
16+
def tag(self) -> str:
17+
"""
18+
Xml element tag.
19+
"""
20+
1421
@abc.abstractmethod
1522
def is_empty(self) -> bool:
1623
"""
@@ -45,6 +52,14 @@ def find_element(
4552
:return: xml element
4653
"""
4754

55+
@abc.abstractmethod
56+
def get_text(self) -> Optional[str]:
57+
"""
58+
Returns the element text.
59+
60+
:return: element text
61+
"""
62+
4863
@abc.abstractmethod
4964
def pop_text(self) -> Optional[str]:
5065
"""
@@ -63,6 +78,14 @@ def pop_attrib(self, name: str) -> Optional[str]:
6378
:return: element attribute
6479
"""
6580

81+
@abc.abstractmethod
82+
def get_attributes(self) -> Optional[Dict[str, str]]:
83+
"""
84+
Returns the element attributes.
85+
86+
:return: element attributes
87+
"""
88+
6689
@abc.abstractmethod
6790
def pop_attributes(self) -> Optional[Dict[str, str]]:
6891
"""
@@ -92,6 +115,14 @@ def find_sub_element(self, path: Sequence[str], search_mode: 'SearchMode') -> Op
92115
:return: found element or `None`
93116
"""
94117

118+
@abc.abstractmethod
119+
def get_elements(self) -> Optional[List['XmlElement[Any]']]:
120+
"""
121+
Returns the element sub-elements.
122+
123+
:return: sub-element
124+
"""
125+
95126
@abc.abstractmethod
96127
def create_snapshot(self) -> 'XmlElement[Any]':
97128
"""
@@ -306,6 +337,9 @@ def append_element(self, element: 'XmlElement[NativeElement]') -> None:
306337
def get_attrib(self, name: str) -> Optional[str]:
307338
return self._state.attrib.get(name, None) if self._state.attrib else None
308339

340+
def get_text(self) -> Optional[str]:
341+
return self._state.text
342+
309343
def pop_text(self) -> Optional[str]:
310344
result, self._state.text = self._state.text, None
311345

@@ -314,6 +348,9 @@ def pop_text(self) -> Optional[str]:
314348
def pop_attrib(self, name: str) -> Optional[str]:
315349
return self._state.attrib.pop(name, None) if self._state.attrib else None
316350

351+
def get_attributes(self) -> Optional[Dict[str, str]]:
352+
return self._state.attrib
353+
317354
def pop_attributes(self) -> Optional[Dict[str, str]]:
318355
result, self._state.attrib = self._state.attrib, None
319356

@@ -358,6 +395,9 @@ def find_element(
358395

359396
return searcher(self._state, tag, look_behind, step_forward)
360397

398+
def get_elements(self) -> Optional[List['XmlElement[NativeElement]']]:
399+
return self._state.elements[self._state.next_element_idx:]
400+
361401

362402
class SearchMode(str, Enum):
363403
"""

pydantic_xml/serializers/factories/model.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import abc
22
import typing
3-
from typing import Any, Dict, Mapping, Optional, Set, Type
3+
from typing import Any, Dict, List, Mapping, Optional, Set, Type
44

5+
import pydantic as pd
6+
import pydantic_core as pdc
57
from pydantic_core import core_schema as pcs
68

79
import pydantic_xml as pxml
@@ -25,6 +27,41 @@ def element_name(self) -> str: ...
2527
@abc.abstractmethod
2628
def nsmap(self) -> Optional[NsMap]: ...
2729

30+
@classmethod
31+
def _check_extra(cls, error_title: str, element: XmlElementReader) -> None:
32+
line_errors: List[pdc.InitErrorDetails] = []
33+
34+
if (text := element.get_text()) is not None:
35+
if text := text.strip():
36+
line_errors.append(
37+
pdc.InitErrorDetails(
38+
type='extra_forbidden',
39+
loc=('<text>',),
40+
input=text,
41+
),
42+
)
43+
if extra_attrs := element.get_attributes():
44+
for name, value in extra_attrs.items():
45+
line_errors.append(
46+
pdc.InitErrorDetails(
47+
type='extra_forbidden',
48+
loc=(f'<attr> {name}',),
49+
input=value,
50+
),
51+
)
52+
if extra_elements := element.get_elements():
53+
for extra_element in extra_elements:
54+
line_errors.append(
55+
pdc.InitErrorDetails(
56+
type='extra_forbidden',
57+
loc=(f'<element> {extra_element.tag}',),
58+
input=extra_element.get_text(),
59+
),
60+
)
61+
62+
if line_errors:
63+
raise pd.ValidationError.from_exception_data(title=error_title, line_errors=line_errors)
64+
2865

2966
class ModelSerializer(BaseModelSerializer):
3067
@classmethod
@@ -157,6 +194,9 @@ def deserialize(
157194
if (field_value := field_serializer.deserialize(element, context=context)) is not None
158195
}
159196

197+
if self._model.model_config.get('extra', 'ignore') == 'forbid':
198+
self._check_extra(self._model.__name__, element)
199+
160200
return self._model.model_validate(result, strict=False, context=context)
161201

162202

@@ -239,6 +279,9 @@ def deserialize(
239279

240280
result = self._root_serializer.deserialize(element, context=context)
241281

282+
if self._model.model_config.get('extra', 'ignore') == 'forbid':
283+
self._check_extra(self._model.__name__, element)
284+
242285
return self._model.model_validate(result, strict=False, context=context)
243286

244287

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "pydantic-xml"
3-
version = "2.1.0"
3+
version = "2.2.0"
44
description = "pydantic xml extension"
55
authors = ["Dmitry Pershin <dapper1291@gmail.com>"]
66
license = "Unlicense"

tests/test_misc.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from typing import Dict, List, Optional, Tuple, Union
2+
from unittest.mock import ANY
23

34
import pydantic as pd
45
import pytest
@@ -253,3 +254,51 @@ def validate_field(cls, v: str, info: pd.FieldValidationInfo):
253254
'''
254255

255256
TestModel.from_xml(xml, validation_context)
257+
258+
259+
@pytest.mark.parametrize('search_mode', ['strict', 'ordered', 'unordered'])
260+
def test_extra_forbid(search_mode: str):
261+
class Model(BaseXmlModel, tag='model', extra='forbid', search_mode=search_mode):
262+
attr1: str = attr()
263+
field1: str = element()
264+
field2: str = wrapped('wrapper', element())
265+
266+
xml = '''
267+
<model attr1="attr value 1" attr2="attr value 2">text value
268+
<field1>field value 1</field1>
269+
<wrapper>
270+
<field2>field value 2</field2>
271+
</wrapper>
272+
<field3>field value 3</field3>
273+
</model>
274+
'''
275+
276+
with pytest.raises(pd.ValidationError) as exc:
277+
Model.from_xml(xml)
278+
279+
err = exc.value
280+
assert err.title == 'Model'
281+
assert err.error_count() == 3
282+
assert err.errors() == [
283+
{
284+
'input': 'text value',
285+
'loc': ('<text>',),
286+
'msg': 'Extra inputs are not permitted',
287+
'type': 'extra_forbidden',
288+
'url': ANY,
289+
},
290+
{
291+
'input': 'attr value 2',
292+
'loc': ('<attr> attr2',),
293+
'msg': 'Extra inputs are not permitted',
294+
'type': 'extra_forbidden',
295+
'url': ANY,
296+
},
297+
{
298+
'input': 'field value 3',
299+
'loc': ('<element> field3',),
300+
'msg': 'Extra inputs are not permitted',
301+
'type': 'extra_forbidden',
302+
'url': ANY,
303+
},
304+
]

0 commit comments

Comments
 (0)