From 2da50d5bf5cdff7af3c245fcb488d6da80abeda6 Mon Sep 17 00:00:00 2001 From: Dennis Hilhorst Date: Wed, 19 Feb 2025 15:23:29 +0100 Subject: [PATCH 1/3] fix XmlElement::pop_element --- pydantic_xml/element/element.py | 9 +++++- tests/test_extra.py | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/pydantic_xml/element/element.py b/pydantic_xml/element/element.py index 6e82093..c868060 100644 --- a/pydantic_xml/element/element.py +++ b/pydantic_xml/element/element.py @@ -370,7 +370,14 @@ def pop_attributes(self) -> Optional[Dict[str, str]]: def pop_element(self, tag: str, search_mode: 'SearchMode') -> 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, False) + if result is not None: + # pop the element, self._state.next_element_idx now points to the first not-found + # element + 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" diff --git a/tests/test_extra.py b/tests/test_extra.py index bcb9e2f..9f15bd9 100644 --- a/tests/test_extra.py +++ b/tests/test_extra.py @@ -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 @@ -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 = ''' + + field value 1nested element field + field value 2 + undefined fieldnested undefined field + + ''' + 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), + }, + }, + ] From 7a474d896a54a9a6117df0c505dfc5e0ff875ab9 Mon Sep 17 00:00:00 2001 From: Dennis Hilhorst Date: Wed, 19 Feb 2025 15:29:59 +0100 Subject: [PATCH 2/3] fix linting --- pydantic_xml/element/element.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pydantic_xml/element/element.py b/pydantic_xml/element/element.py index c868060..c12dd3f 100644 --- a/pydantic_xml/element/element.py +++ b/pydantic_xml/element/element.py @@ -378,7 +378,6 @@ def pop_element(self, tag: str, search_mode: 'SearchMode') -> Optional['XmlEleme 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" From de14fbede7f9058a8ec00c719cf623ddaf104109 Mon Sep 17 00:00:00 2001 From: Dennis Hilhorst Date: Wed, 19 Feb 2025 16:10:39 +0100 Subject: [PATCH 3/3] okay I guess this is expected behavior in other places --- pydantic_xml/element/element.py | 14 ++++++++------ pydantic_xml/serializers/factories/raw.py | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/pydantic_xml/element/element.py b/pydantic_xml/element/element.py index c12dd3f..47d43b6 100644 --- a/pydantic_xml/element/element.py +++ b/pydantic_xml/element/element.py @@ -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 """ @@ -367,14 +368,15 @@ 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) # don't step forward, self._state.next_element_idx points to the desired element - result = searcher(self._state, tag, False, False) - if result is not None: - # pop the element, self._state.next_element_idx now points to the first not-found - # 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 diff --git a/pydantic_xml/serializers/factories/raw.py b/pydantic_xml/serializers/factories/raw.py index 2fa9390..0708ea9 100644 --- a/pydantic_xml/serializers/factories/raw.py +++ b/pydantic_xml/serializers/factories/raw.py @@ -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: