Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions pydantic_xml/element/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,13 @@ def pop_attributes(self) -> Optional[Dict[str, str]]:
"""

@abc.abstractmethod
def pop_element(self, tag: str, search_mode: 'SearchMode') -> Optional['XmlElementReader']:
def pop_element(self, tag: str, search_mode: 'SearchMode', remove: bool = False) -> Optional['XmlElementReader']:
"""
Extracts a sub-element from the xml element matching `tag`.

:param tag: element tag
:param search_mode: element search mode
:param remove: whether to _actually_ remove the element (e.g. for ElementT fields)
:return: sub-element
"""

Expand Down Expand Up @@ -367,10 +368,17 @@ def pop_attributes(self) -> Optional[Dict[str, str]]:

return result

def pop_element(self, tag: str, search_mode: 'SearchMode') -> Optional['XmlElement[NativeElement]']:
def pop_element(self, tag: str, search_mode: 'SearchMode', remove: bool = False) -> Optional['XmlElement[NativeElement]']:
searcher: Searcher[NativeElement] = get_searcher(search_mode)

return searcher(self._state, tag, False, True)
# don't step forward, self._state.next_element_idx points to the desired element
result = searcher(self._state, tag, False, True)
if result is not None and remove:
# remove the element, self._state.next_element_idx now points to the first
# not-found element
self._state.next_element_idx -= 1
self._state.elements.pop(self._state.next_element_idx)
return result

def find_sub_element(self, path: Sequence[str], search_mode: 'SearchMode') -> PathT['XmlElement[NativeElement]']:
assert len(path) > 0, "path can't be empty"
Expand Down
2 changes: 1 addition & 1 deletion pydantic_xml/serializers/factories/raw.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def deserialize(
if element is None:
return None

if (sub_element := element.pop_element(self._element_name, self._search_mode)) is not None:
if (sub_element := element.pop_element(self._element_name, self._search_mode, remove=True)) is not None:
sourcemap[loc] = sub_element.get_sourceline()
return sub_element.to_native()
else:
Expand Down
50 changes: 50 additions & 0 deletions tests/test_extra.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pytest

from pydantic_xml import BaseXmlModel, attr, element, wrapped
from pydantic_xml.element.native import ElementT
from tests.helpers import fmt_sourceline


Expand Down Expand Up @@ -230,3 +231,52 @@ class TestModel(BaseXmlModel, tag='model', extra='forbid', search_mode=search_mo
},
},
]


@pytest.mark.parametrize('search_mode', ['strict', 'ordered', 'unordered'])
def test_raw_extra_forbid(search_mode: str):
class TestModel(
BaseXmlModel,
tag='model',
extra='forbid',
arbitrary_types_allowed=True,
search_mode=search_mode
):
field1: ElementT = element("field1")
field2: ElementT | None = element("field2", default=None)

xml = '''
<model>
<field1>field value 1<nested>nested element field</nested></field1>
<field2>field value 2</field2>
<extra>undefined field<nested>nested undefined field</nested></extra>
</model>
'''
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': 'undefined field',
'loc': ('extra',),
'msg': f'[line {fmt_sourceline(5)}]: Extra inputs are not permitted',
'type': 'extra_forbidden',
'ctx': {
'orig': 'Extra inputs are not permitted',
'sourceline': fmt_sourceline(5),
},
},
{
'input': 'nested undefined field',
'loc': ('extra', 'nested'),
'msg': f'[line {fmt_sourceline(5)}]: Extra inputs are not permitted',
'type': 'extra_forbidden',
'ctx': {
'orig': 'Extra inputs are not permitted',
'sourceline': fmt_sourceline(5),
},
},
]
Loading