From f9cf6b42f41325370b918441c7fbd18ca752ea26 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Tue, 6 Feb 2018 14:29:56 +0000 Subject: [PATCH] Create model method (#125) * adding create_model method * adding method * docs and tweaks * prevent config and base together * tweak docs --- HISTORY.rst | 3 +- docs/examples/dynamic_inheritance.py | 13 +++++ docs/examples/dynamic_model_creation.py | 8 +++ docs/index.rst | 16 ++++++ pydantic/__init__.py | 2 +- pydantic/main.py | 57 ++++++++++++++++++- tests/test_create_model.py | 75 +++++++++++++++++++++++++ 7 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 docs/examples/dynamic_inheritance.py create mode 100644 docs/examples/dynamic_model_creation.py create mode 100644 tests/test_create_model.py diff --git a/HISTORY.rst b/HISTORY.rst index 3cfa960..4adf84f 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,9 +3,10 @@ History ------- -v0.6.5 (2018-02-XX) +v0.7.0 (2018-02-XX) ................... * added compatibility with abstract base classes (ABCs) #123 +* add ``create_model`` method #113 #125 v0.6.4 (2018-02-01) ................... diff --git a/docs/examples/dynamic_inheritance.py b/docs/examples/dynamic_inheritance.py new file mode 100644 index 0000000..20db4f8 --- /dev/null +++ b/docs/examples/dynamic_inheritance.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel, create_model + + +class FooModel(BaseModel): + foo: str + bar: int = 123 + + +BarModel = create_model('BarModel', apple='russet', banana='yellow', __base__=FooModel) +print(BarModel) +# > +print(', '.join(BarModel.__fields__.keys())) +# > foo, bar, apple, banana diff --git a/docs/examples/dynamic_model_creation.py b/docs/examples/dynamic_model_creation.py new file mode 100644 index 0000000..7e5d5c3 --- /dev/null +++ b/docs/examples/dynamic_model_creation.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel, create_model + +DynamicFoobarModel = create_model('DynamicFoobarModel', foo=(str, ...), bar=123) + + +class StaticFoobarModel(BaseModel): + foo: str + bar: int = 123 diff --git a/docs/index.rst b/docs/index.rst index 9536bc2..383cd54 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -252,6 +252,22 @@ Here ``redis_port`` could be modified via ``export MY_PREFIX_REDIS_PORT=6380`` o Complex types like ``list``, ``set``, ``dict`` and submodels can be set by using JSON environment variables. +Dynamic model creation +...................... + +There are some occasions where the shape of a model is not known until runtime, for this *pydantic* provides +the ``create_model`` method to allow models to be created on the fly. + +.. literalinclude:: examples/dynamic_model_creation.py + +Here ``StaticFoobarModel`` and ``DynamicFoobarModel`` are identical. + +Fields are defined by either a a tuple of the form ``(, )`` or just a default value. The +special key word arguments ``__config__`` and ``__base__`` can be used to customise the new model. This includes +extending a base model with extra fields. + +.. literalinclude:: examples/dynamic_inheritance.py + .. _usage_mypy: Usage with mypy diff --git a/pydantic/__init__.py b/pydantic/__init__.py index 792c5cb..5285b5d 100644 --- a/pydantic/__init__.py +++ b/pydantic/__init__.py @@ -2,7 +2,7 @@ from .env_settings import BaseSettings from .exceptions import * from .fields import Required -from .main import BaseModel, validator +from .main import BaseModel, create_model, validator from .parse import Protocol from .types import * from .version import VERSION diff --git a/pydantic/main.py b/pydantic/main.py index abffb96..c42e473 100644 --- a/pydantic/main.py +++ b/pydantic/main.py @@ -3,7 +3,7 @@ from abc import ABCMeta from collections import OrderedDict from pathlib import Path from types import FunctionType -from typing import Any, Dict, Set, Union +from typing import Any, Dict, Set, Type, Union from .exceptions import ConfigError, Error, Extra, Missing, ValidationError from .fields import Field, Validator @@ -96,6 +96,7 @@ class MetaModel(ABCMeta): new_namespace = { 'config': config, '__fields__': fields, + '__validators__': validators, **{n: v for n, v in namespace.items() if n not in fields} } return super().__new__(mcs, name, bases, new_namespace) @@ -109,6 +110,8 @@ EXTRA_ERROR = Error(Extra('extra fields not permitted'), None, None) class BaseModel(metaclass=MetaModel): # populated by the metaclass, defined here to help IDEs only __fields__ = {} + __validators__ = {} + Config = BaseConfig __slots__ = '__values__', @@ -308,6 +311,58 @@ class BaseModel(metaclass=MetaModel): return self.to_string() +def create_model( + model_name: str, *, + __config__: Type[BaseConfig]=None, + __base__: Type[BaseModel]=None, + **field_definitions): + """ + Dynamically create a model. + :param model_name: name of the created model + :param __config__: config class to use for the new model + :param __base__: base class for the new model to inherit from + :param **field_definitions: fields of the model (or extra fields if a base is supplied) in the format + `=(, )` or `= eg. `foobar=(str, ...)` or `foobar=123` + """ + if __base__: + fields = __base__.__fields__ + validators = __base__.__validators__ + if __config__ is not None: + raise ConfigError('to avoid confusion __config__ and __base__ cannot be used together') + else: + __base__ = BaseModel + fields = OrderedDict() + validators = {} + + config = __config__ or BaseConfig + + for f_name, f_def in field_definitions.items(): + if isinstance(f_def, tuple): + try: + f_annotation, f_value = f_def + except ValueError as e: + raise ConfigError(f'field definitions should either be a tuple of (, ) or just a ' + f'default value, unfortunately this means tuples as ' + f'default values are not allowed') from e + else: + f_annotation, f_value = None, f_def + if f_name.startswith('_'): + warnings.warn(f'fields may not start with an underscore, ignoring "{f_name}"', RuntimeWarning) + else: + fields[f_name] = Field.infer( + name=f_name, + value=f_value, + annotation=f_annotation, + class_validators=validators.get(f_name), + config=config, + ) + namespace = { + 'config': config, + '__fields__': fields, + } + return type(model_name, (__base__,), namespace) + + _FUNCS = set() diff --git a/tests/test_create_model.py b/tests/test_create_model.py new file mode 100644 index 0000000..6edda9a --- /dev/null +++ b/tests/test_create_model.py @@ -0,0 +1,75 @@ +import pytest + +from pydantic import BaseModel, ConfigError, ValidationError, create_model, validator + + +def test_create_model(): + model = create_model('FooModel', foo=(str, ...), bar=123) + assert issubclass(model, BaseModel) + assert issubclass(model.config, BaseModel.Config) + assert model.__name__ == 'FooModel' + assert model.__fields__.keys() == {'foo', 'bar'} + assert model.__validators__ == {} + assert model.config.__name__ == 'BaseConfig' + + +def test_create_model_usage(): + model = create_model('FooModel', foo=(str, ...), bar=123) + m = model(foo='hello') + assert m.foo == 'hello' + assert m.bar == 123 + with pytest.raises(ValidationError): + model() + with pytest.raises(ValidationError): + model(foo='hello', bar='xxx') + + +def test_invalid_name(): + with pytest.warns(RuntimeWarning): + model = create_model('FooModel', _foo=(str, ...)) + assert len(model.__fields__) == 0 + + +def test_field_wrong_tuple(): + with pytest.raises(ConfigError): + create_model('FooModel', foo=(1, 2, 3)) + + +def test_config_and_base(): + with pytest.raises(ConfigError): + create_model('FooModel', __config__=BaseModel.Config, __base__=BaseModel) + + +def test_inheritance(): + class BarModel(BaseModel): + x = 1 + y = 2 + model = create_model('FooModel', foo=(str, ...), bar=(int, 123), __base__=BarModel) + assert model.__fields__.keys() == {'foo', 'bar', 'x', 'y'} + m = model(foo='a', x=4) + assert m.dict() == {'bar': 123, 'foo': 'a', 'x': 4, 'y': 2} + + +def test_custom_config(): + class Config(BaseModel.Config): + fields = { + 'foo': 'api-foo-field' + } + model = create_model('FooModel', foo=(int, ...), __config__=Config) + assert model(**{'api-foo-field': '987'}).foo == 987 + with pytest.raises(ValidationError): + model(foo=654) + + +def test_inheritance_validators(): + class BarModel(BaseModel): + @validator('a') + def check_a(cls, v): + if 'foobar' not in v: + raise ValueError('"foobar" not found in a') + return v + + model = create_model('FooModel', a='cake', __base__=BarModel) + assert model().a == 'cake' + with pytest.raises(ValidationError): + model(a='something else')