From 11e86a0d96b8fdeb3c30bb1b4d52acceba0d1aa3 Mon Sep 17 00:00:00 2001 From: Dmitry Pershin Date: Tue, 7 May 2024 22:39:19 +0500 Subject: [PATCH 1/2] dynamic model creation support added. --- README.rst | 1 - docs/source/pages/misc.rst | 15 ++ examples/snippets/dynamic_model_creation.py | 27 +++ pydantic_xml/__init__.py | 3 +- pydantic_xml/model.py | 66 +++++ tests/test_dynamic_model_creation.py | 254 ++++++++++++++++++++ 6 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 examples/snippets/dynamic_model_creation.py create mode 100644 tests/test_dynamic_model_creation.py diff --git a/README.rst b/README.rst index b272971..29eb883 100644 --- a/README.rst +++ b/README.rst @@ -42,7 +42,6 @@ Features What is not supported? ______________________ -- `dynamic model creation `_ - `dataclasses `_ Getting started diff --git a/docs/source/pages/misc.rst b/docs/source/pages/misc.rst index 1969a8f..eb301cf 100644 --- a/docs/source/pages/misc.rst +++ b/docs/source/pages/misc.rst @@ -215,6 +215,21 @@ Standard library serializer also supports customizations. For more information see :py:func:`xml.etree.ElementTree.tostring`, +Dynamic model creation +~~~~~~~~~~~~~~~~~~~~~~ + +There are some cases when it is necessary to create a model using runtime information to describe model fields. +For this ``pydantic-xml`` provides the :py:func:`pydantic_xml.create_model` function to create a model on the fly: + +.. literalinclude:: ../../../examples/snippets/dynamic_model_creation.py + :language: python + :start-after: model-start + :end-before: model-end + +Field specification syntax is similar to ``pydantic`` one. For more information +see the `documentation `_. + + Mypy ~~~~ diff --git a/examples/snippets/dynamic_model_creation.py b/examples/snippets/dynamic_model_creation.py new file mode 100644 index 0000000..a90b078 --- /dev/null +++ b/examples/snippets/dynamic_model_creation.py @@ -0,0 +1,27 @@ +from pydantic_xml import attr, create_model + +# [model-start] +Company = create_model( + 'Company', + trade_name=(str, attr(name='trade-name')), + type=(str, attr()), +) + +# [model-end] + + +# [xml-start] +xml_doc = ''' + +''' # [xml-end] + +# [json-start] +json_doc = ''' +{ + "trade_name": "SpaceX", + "type": "Private" +} +''' # [json-end] + +company = Company.from_xml(xml_doc) +assert company == Company.model_validate_json(json_doc) diff --git a/pydantic_xml/__init__.py b/pydantic_xml/__init__.py index 399bf8f..a72551b 100644 --- a/pydantic_xml/__init__.py +++ b/pydantic_xml/__init__.py @@ -4,7 +4,7 @@ from . import config, errors, model from .errors import ModelError, ParsingError -from .model import BaseXmlModel, RootXmlModel, attr, computed_attr, computed_element, element, wrapped +from .model import BaseXmlModel, RootXmlModel, attr, computed_attr, computed_element, create_model, element, wrapped __all__ = ( 'BaseXmlModel', @@ -16,6 +16,7 @@ 'wrapped', 'computed_attr', 'computed_element', + 'create_model', 'errors', 'model', ) diff --git a/pydantic_xml/model.py b/pydantic_xml/model.py index d7fe212..daad77b 100644 --- a/pydantic_xml/model.py +++ b/pydantic_xml/model.py @@ -19,6 +19,7 @@ __all__ = ( 'attr', + 'create_model', 'element', 'wrapped', 'computed_attr', @@ -250,6 +251,70 @@ def wrapped( ) +Model = TypeVar('Model', bound='BaseXmlModel') + + +def create_model( + __model_name: str, + *, + __tag__: Optional[str] = None, + __ns__: Optional[str] = None, + __nsmap__: Optional[NsMap] = None, + __ns_attrs__: Optional[bool] = None, + __skip_empty__: Optional[bool] = None, + __search_mode__: Optional[SearchMode] = None, + __base__: Union[Type[Model], Tuple[Type[Model], ...], None] = None, + __module__: Optional[str] = None, + **kwargs: Any, +) -> Type[Model]: + """ + Dynamically creates a new pydantic-xml model. + + :param __model_name: model name + :param __tag__: element tag + :param __ns__: element namespace + :param __nsmap__: element namespace map + :param __ns_attrs__: use namespaced attributes + :param __skip_empty__: skip empty elements (elements without sub-elements, attributes and text) + :param __search_mode__: element search mode + :param __base__: model base class + :param __module__: module name that the model belongs to + :param kwargs: pydantic model creation arguments. + See https://docs.pydantic.dev/latest/api/base_model/#pydantic.create_model. + + :return: created model + """ + + cls_kwargs = kwargs.setdefault('__cls_kwargs__', {}) + cls_kwargs['metaclass'] = XmlModelMeta + + cls_kwargs['tag'] = __tag__ + cls_kwargs['ns'] = __ns__ + cls_kwargs['nsmap'] = __nsmap__ + cls_kwargs['ns_attrs'] = __ns_attrs__ + cls_kwargs['skip_empty'] = __skip_empty__ + cls_kwargs['search_mode'] = __search_mode__ + + model_base: Union[Type[BaseModel], Tuple[Type[BaseModel], ...]] = __base__ or BaseXmlModel + + if model_config := kwargs.pop('__config__', None): + # since pydantic create_model function forbids __base__ and __config__ arguments together, + # we create base pydantic class with __config__ and inherit from it + BaseWithConfig = pd.create_model( + f'{__model_name}Base', + __module__=__module__, # type: ignore[arg-type] + __config__=model_config, + ) + if not isinstance(model_base, tuple): + model_base = (model_base, BaseWithConfig) + else: + model_base = (*model_base, BaseWithConfig) + + model = pd.create_model(__model_name, __base__=model_base, **kwargs) + + return typing.cast(Type[Model], model) + + @te.dataclass_transform(kw_only_default=True, field_specifiers=(attr, element, wrapped, pd.Field)) class XmlModelMeta(ModelMetaclass): """ @@ -305,6 +370,7 @@ def __init_subclass__( :param ns: element namespace :param nsmap: element namespace map :param ns_attrs: use namespaced attributes + :param skip_empty: skip empty elements (elements without sub-elements, attributes and text) :param search_mode: element search mode """ diff --git a/tests/test_dynamic_model_creation.py b/tests/test_dynamic_model_creation.py new file mode 100644 index 0000000..f48e903 --- /dev/null +++ b/tests/test_dynamic_model_creation.py @@ -0,0 +1,254 @@ +import datetime as dt +import sys +from typing import Dict, Generic, List, Optional, TypeVar, Union + +import pytest +from helpers import assert_xml_equal +from pydantic import ConfigDict + +from pydantic_xml import BaseXmlModel, RootXmlModel, attr, create_model, element, wrapped + + +def test_primitive_types(): + TestModel = create_model( + 'TestModel', + __tag__='model', + data=(str, ...), + attr1=(int, attr()), + element1=(dt.datetime, element()), + element2=(float, wrapped('element2')), + element3=(Optional[str], element(default=None)), + ) + + xml = ''' + text2022-07-29T23:38:172.2 + ''' + + actual_obj = TestModel.from_xml(xml) + expected_obj = TestModel( + data='text', + attr1=1, + element1=dt.datetime(2022, 7, 29, 23, 38, 17), + element2=2.2, + element3=None, + ) + + assert actual_obj == expected_obj + + actual_xml = actual_obj.to_xml() + assert_xml_equal(actual_xml, xml) + + +def test_non_primitive_types(): + TestModel = create_model( + 'TestModel', + attr1=(Union[int, str], attr()), + element1=(Dict[str, int], element(tag='sub1')), + element2=(List[float], wrapped('wrap', element())), + ) + + xml = ''' + + + + 1.1 + 2.2 + + + ''' + + actual_obj = TestModel.from_xml(xml) + expected_obj = TestModel( + attr1="hello", + element1={'attr1': 1, 'attr2': 2}, + element2=[1.1, 2.2], + ) + + assert actual_obj == expected_obj + + actual_xml = actual_obj.to_xml() + assert_xml_equal(actual_xml, xml) + + +def test_root_model(): + TestModel = create_model( + 'TestModel', + __tag__='model', + __base__=RootXmlModel, + root=(int, attr(name="attr1")), + ) + + xml = ''' + + ''' + + actual_obj = TestModel.from_xml(xml) + expected_obj = TestModel(1) + + assert actual_obj == expected_obj + + actual_xml = actual_obj.to_xml() + assert_xml_equal(actual_xml, xml) + + +def test_nested_model(): + TestSubModel = create_model( + 'TestSubModel', + __tag__='sub-model', + data=(str, ...), + ) + TestModel = create_model( + 'TestModel', + __tag__='model', + sub=(TestSubModel, ...), + ) + + xml = ''' + + text + + ''' + + actual_obj = TestModel.from_xml(xml) + expected_obj = TestModel( + sub=TestSubModel( + data='text', + ), + ) + + assert actual_obj == expected_obj + + actual_xml = actual_obj.to_xml() + assert_xml_equal(actual_xml, xml) + + +def test_inheritance(): + TestBaseModel = create_model( + 'TestBaseModel', + data=(str, ...), + ) + + TestModel = create_model( + 'TestModel', + __base__=TestBaseModel, + __tag__='model', + attr1=(int, attr()), + ) + + xml = ''' + text + ''' + + actual_obj = TestModel.from_xml(xml) + expected_obj = TestModel( + data='text', + attr1=1, + ) + + assert actual_obj == expected_obj + + actual_xml = actual_obj.to_xml() + assert_xml_equal(actual_xml, xml) + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason="requires python 3.9 and above") +def test_annotations(): + from typing import Annotated + + TestModel = create_model( + 'TestModel', + __tag__='model', + attr1=Annotated[int, attr()], + element1=Annotated[str, element()], + ) + + xml = ''' + + text + + ''' + + actual_obj = TestModel.from_xml(xml) + expected_obj = TestModel(attr1=1, element1='text') + + assert actual_obj == expected_obj + + actual_xml = actual_obj.to_xml() + assert_xml_equal(actual_xml, xml) + + +def test_generics(): + GenericType1 = TypeVar('GenericType1') + GenericType2 = TypeVar('GenericType2') + + GenericModel = create_model( + 'GenericModel', + __tag__='model', + __base__=(BaseXmlModel, Generic[GenericType1, GenericType2]), + attr1=(GenericType1, attr()), + attr2=(GenericType2, attr()), + ) + + xml1 = ''' + + ''' + + TestModel = GenericModel[int, float] + actual_obj = TestModel.from_xml(xml1) + expected_obj = TestModel(attr1=1, attr2=2.2) + + assert actual_obj == expected_obj + + actual_xml = actual_obj.to_xml() + assert_xml_equal(actual_xml, xml1) + + xml2 = ''' + + ''' + + TestModel = GenericModel[bool, str] + actual_obj = TestModel.from_xml(xml2) + expected_obj = TestModel(attr1=True, attr2="string") + + assert actual_obj == expected_obj + + actual_xml = actual_obj.to_xml() + assert_xml_equal(actual_xml, xml2) + + +def test_config(): + TestBaseModel1 = create_model( + 'TestBaseModel1', + __config__=ConfigDict(str_strip_whitespace=False, str_to_lower=True), + data=(str, ...), + ) + TestBaseModel2 = create_model( + 'TestBaseModel2', + __config__=ConfigDict(str_strip_whitespace=False, str_max_length=4), + data=(str, ...), + ) + + TestModel = create_model( + 'TestModel', + __base__=(TestBaseModel1, TestBaseModel2), + __config__=ConfigDict(str_strip_whitespace=True), + __tag__='model', + data=(str, ...), + ) + + assert TestModel.model_config == ConfigDict( + str_strip_whitespace=True, + str_to_lower=True, + str_max_length=4, + ) + + xml = ''' + + TEXT + + ''' + + actual_obj = TestModel.from_xml(xml) + expected_obj = TestModel(data='text') + + assert actual_obj == expected_obj From 0e2c20987f83876e8e3916ed8690a7df2aaeb699 Mon Sep 17 00:00:00 2001 From: Dmitry Pershin Date: Thu, 9 May 2024 00:35:30 +0500 Subject: [PATCH 2/2] bump version 2.10.0. --- CHANGELOG.rst | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b344afe..870cb13 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,12 @@ Changelog ========= +2.10.0 (2024-05-09) +------------------ + +- dynamic model creation support added. See https://pydantic-xml.readthedocs.io/en/latest/pages/misc.html#dynamic-model-creation + + 2.9.2 (2024-04-19) ------------------ diff --git a/pyproject.toml b/pyproject.toml index ca54b95..f7099de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pydantic-xml" -version = "2.9.2" +version = "2.10.0" description = "pydantic xml extension" authors = ["Dmitry Pershin "] license = "Unlicense"