From 77b1bbe5122757773530df2d29724d9f7f83b6ce Mon Sep 17 00:00:00 2001 From: Dmitry Pershin Date: Mon, 28 Aug 2023 21:45:51 +0500 Subject: [PATCH 1/2] pydantic extra='forbid' parameter is being applied to xml elements too. --- pydantic_xml/element/element.py | 40 +++++++++++++++++ pydantic_xml/serializers/factories/model.py | 45 ++++++++++++++++++- tests/test_misc.py | 49 +++++++++++++++++++++ 3 files changed, 133 insertions(+), 1 deletion(-) diff --git a/pydantic_xml/element/element.py b/pydantic_xml/element/element.py index f355b29..4b59b3e 100644 --- a/pydantic_xml/element/element.py +++ b/pydantic_xml/element/element.py @@ -11,6 +11,13 @@ class XmlElementReader(abc.ABC): Provides an interface for extracting element text, attributes and sub-elements. """ + @property + @abc.abstractmethod + def tag(self) -> str: + """ + Xml element tag. + """ + @abc.abstractmethod def is_empty(self) -> bool: """ @@ -45,6 +52,14 @@ def find_element( :return: xml element """ + @abc.abstractmethod + def get_text(self) -> Optional[str]: + """ + Returns the element text. + + :return: element text + """ + @abc.abstractmethod def pop_text(self) -> Optional[str]: """ @@ -63,6 +78,14 @@ def pop_attrib(self, name: str) -> Optional[str]: :return: element attribute """ + @abc.abstractmethod + def get_attributes(self) -> Optional[Dict[str, str]]: + """ + Returns the element attributes. + + :return: element attributes + """ + @abc.abstractmethod def pop_attributes(self) -> Optional[Dict[str, str]]: """ @@ -92,6 +115,14 @@ def find_sub_element(self, path: Sequence[str], search_mode: 'SearchMode') -> Op :return: found element or `None` """ + @abc.abstractmethod + def get_elements(self) -> Optional[List['XmlElement[Any]']]: + """ + Returns the element sub-elements. + + :return: sub-element + """ + @abc.abstractmethod def create_snapshot(self) -> 'XmlElement[Any]': """ @@ -306,6 +337,9 @@ def append_element(self, element: 'XmlElement[NativeElement]') -> None: def get_attrib(self, name: str) -> Optional[str]: return self._state.attrib.get(name, None) if self._state.attrib else None + def get_text(self) -> Optional[str]: + return self._state.text + def pop_text(self) -> Optional[str]: result, self._state.text = self._state.text, None @@ -314,6 +348,9 @@ def pop_text(self) -> Optional[str]: def pop_attrib(self, name: str) -> Optional[str]: return self._state.attrib.pop(name, None) if self._state.attrib else None + def get_attributes(self) -> Optional[Dict[str, str]]: + return self._state.attrib + def pop_attributes(self) -> Optional[Dict[str, str]]: result, self._state.attrib = self._state.attrib, None @@ -358,6 +395,9 @@ def find_element( return searcher(self._state, tag, look_behind, step_forward) + def get_elements(self) -> Optional[List['XmlElement[NativeElement]']]: + return self._state.elements[self._state.next_element_idx:] + class SearchMode(str, Enum): """ diff --git a/pydantic_xml/serializers/factories/model.py b/pydantic_xml/serializers/factories/model.py index bdd5ec1..f5015d0 100644 --- a/pydantic_xml/serializers/factories/model.py +++ b/pydantic_xml/serializers/factories/model.py @@ -1,7 +1,9 @@ import abc import typing -from typing import Any, Dict, Mapping, Optional, Set, Type +from typing import Any, Dict, List, Mapping, Optional, Set, Type +import pydantic as pd +import pydantic_core as pdc from pydantic_core import core_schema as pcs import pydantic_xml as pxml @@ -25,6 +27,41 @@ def element_name(self) -> str: ... @abc.abstractmethod def nsmap(self) -> Optional[NsMap]: ... + @classmethod + def _check_extra(cls, error_title: str, element: XmlElementReader) -> None: + line_errors: List[pdc.InitErrorDetails] = [] + + if (text := element.get_text()) is not None: + if text := text.strip(): + line_errors.append( + pdc.InitErrorDetails( + type='extra_forbidden', + loc=('',), + input=text, + ), + ) + if extra_attrs := element.get_attributes(): + for name, value in extra_attrs.items(): + line_errors.append( + pdc.InitErrorDetails( + type='extra_forbidden', + loc=(f' {name}',), + input=value, + ), + ) + if extra_elements := element.get_elements(): + for extra_element in extra_elements: + line_errors.append( + pdc.InitErrorDetails( + type='extra_forbidden', + loc=(f' {extra_element.tag}',), + input=extra_element.get_text(), + ), + ) + + if line_errors: + raise pd.ValidationError.from_exception_data(title=error_title, line_errors=line_errors) + class ModelSerializer(BaseModelSerializer): @classmethod @@ -157,6 +194,9 @@ def deserialize( if (field_value := field_serializer.deserialize(element, context=context)) is not None } + if self._model.model_config.get('extra', 'ignore') == 'forbid': + self._check_extra(self._model.__name__, element) + return self._model.model_validate(result, strict=False, context=context) @@ -239,6 +279,9 @@ def deserialize( result = self._root_serializer.deserialize(element, context=context) + if self._model.model_config.get('extra', 'ignore') == 'forbid': + self._check_extra(self._model.__name__, element) + return self._model.model_validate(result, strict=False, context=context) diff --git a/tests/test_misc.py b/tests/test_misc.py index 042e267..6d4d06f 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -1,4 +1,5 @@ from typing import Dict, List, Optional, Tuple, Union +from unittest.mock import ANY import pydantic as pd import pytest @@ -253,3 +254,51 @@ def validate_field(cls, v: str, info: pd.FieldValidationInfo): ''' TestModel.from_xml(xml, validation_context) + + +@pytest.mark.parametrize('search_mode', ['strict', 'ordered', 'unordered']) +def test_extra_forbid(search_mode: str): + class Model(BaseXmlModel, tag='model', extra='forbid', search_mode=search_mode): + attr1: str = attr() + field1: str = element() + field2: str = wrapped('wrapper', element()) + + xml = ''' + text value + field value 1 + + field value 2 + + field value 3 + + ''' + + with pytest.raises(pd.ValidationError) as exc: + Model.from_xml(xml) + + err = exc.value + assert err.title == 'Model' + assert err.error_count() == 3 + assert err.errors() == [ + { + 'input': 'text value', + 'loc': ('',), + 'msg': 'Extra inputs are not permitted', + 'type': 'extra_forbidden', + 'url': ANY, + }, + { + 'input': 'attr value 2', + 'loc': (' attr2',), + 'msg': 'Extra inputs are not permitted', + 'type': 'extra_forbidden', + 'url': ANY, + }, + { + 'input': 'field value 3', + 'loc': (' field3',), + 'msg': 'Extra inputs are not permitted', + 'type': 'extra_forbidden', + 'url': ANY, + }, + ] From 8aed8168da3aa32ae078015cc8b12f16bcb1f5c1 Mon Sep 17 00:00:00 2001 From: Dmitry Pershin Date: Thu, 7 Sep 2023 19:54:12 +0500 Subject: [PATCH 2/2] bump version 2.2.0 --- CHANGELOG.rst | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fcc3bfa..6a350f1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,13 @@ Changelog ========= +2.2.0 (2023-09-07) +------------------ + +- pydantic extra='forbid' parameter is being applied to xml elements too. See https://github.com/dapper91/pydantic-xml/pull/106. + + + 2.1.0 (2023-08-24) ------------------ diff --git a/pyproject.toml b/pyproject.toml index 4246af9..fa4533e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pydantic-xml" -version = "2.1.0" +version = "2.2.0" description = "pydantic xml extension" authors = ["Dmitry Pershin "] license = "Unlicense"