diff --git a/HISTORY.rst b/HISTORY.rst index ee37351..b17ad21 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -6,6 +6,8 @@ History v0.3.0 (TBC) ............ * immutable models via ``config.allow_mutation = False``, associated cleanup and performance improvement #44 +* immutable helper methods ``construct()`` and ``copy()`` #53 +* allow pickling of models #53 * ``setattr`` is removed as ``__setattr__`` is now intelligent #44 * ``raise_exception`` removed, Models now always raise exceptions #44 * instance method validators removed TODO diff --git a/docs/examples/config.py b/docs/examples/config.py index 513c3bc..d9d7e91 100644 --- a/docs/examples/config.py +++ b/docs/examples/config.py @@ -1,16 +1,19 @@ -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError -class UserModel(BaseModel): - id: int = ... +class Model(BaseModel): + v: str class Config: - min_anystr_length = 0 # min length for str & byte types - max_anystr_length = 2 ** 16 # max length for str & byte types - min_number_size = -2 ** 64 # min size for numbers - max_number_size = 2 ** 64 # max size for numbers - validate_all = False # whether or not to validate field defaults - ignore_extra = True # whether to ignore any extra values in input data - allow_extra = False # whether or not too allow (and include on the model) any extra values in input data - allow_mutation = True # whether or not models are faux-immutable, eg. setattr fails on model fields - fields = None # extra information on each field, currently just "alias is allowed" + max_anystr_length = 10 + + +try: + Model(v='x' * 20) +except ValidationError as e: + print(e) +""" +1 error validating input +v: + length not in range 0 to 10 (error_type=ValueError track=str) +""" diff --git a/docs/examples/copy_values.py b/docs/examples/copy_values.py new file mode 100644 index 0000000..b74e4f1 --- /dev/null +++ b/docs/examples/copy_values.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel + + +class BarModel(BaseModel): + whatever: int + + +class FooBarModel(BaseModel): + banana: float + foo: str + bar: BarModel + + +m = FooBarModel(banana=3.14, foo='hello', bar={'whatever': 123}) + +print(m.values()) +# > {'banana': 3.14, 'foo': 'hello', 'bar': {'whatever': 123}} + +print(m.values(include={'foo', 'bar'})) +# > {'foo': 'hello', 'bar': {'whatever': 123}} + +print(m.values(exclude={'foo', 'bar'})) +# > {'banana': 3.14} + +print(m.copy()) +# > FooBarModel banana=3.14 foo='hello' bar= + +print(m.copy(include={'foo', 'bar'})) +# > FooBarModel foo='hello' bar= + +print(m.copy(exclude={'foo', 'bar'})) +# > FooBarModel banana=3.14 + +print(m.copy(update={'banana': 0})) +# > FooBarModel banana=0 foo='hello' bar= diff --git a/docs/examples/ex_pickle.py b/docs/examples/ex_pickle.py new file mode 100644 index 0000000..a9be99c --- /dev/null +++ b/docs/examples/ex_pickle.py @@ -0,0 +1,20 @@ +import pickle +from pydantic import BaseModel + + +class FooBarModel(BaseModel): + a: str + b: int + + +m = FooBarModel(a='hello', b=123) +print(m) +# > FooBarModel a='hello' b=123 + +data = pickle.dumps(m) +print(data) +# > b'\x80\x03c...' + +m2 = pickle.loads(data) +print(m2) +# > FooBarModel a='hello' b=123 diff --git a/docs/examples/mutation.py b/docs/examples/mutation.py new file mode 100644 index 0000000..b45836f --- /dev/null +++ b/docs/examples/mutation.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel + + +class FooBarModel(BaseModel): + a: str + b: dict + + class Config: + allow_mutation = False + + +foobar = FooBarModel(a='hello', b={'apple': 'pear'}) + +try: + foobar.a = 'different' +except TypeError as e: + print(e) + # > "FooBarModel" is immutable and does not support item assignment + +print(foobar.a) +# > hello + +print(foobar.b) +# > {'apple': 'pear'} + +foobar.b['apple'] = 'grape' +print(foobar.b) +# > {'apple': 'grape'} diff --git a/docs/index.rst b/docs/index.rst index 3ac47b2..153a59d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -130,7 +130,17 @@ Model Config Behaviour of pydantic can be controlled via the ``Config`` class on a model. -Here default for config parameter are shown together with their meaning. +Options: + +:min_anystr_length: min length for str & byte types (default: ``0``) +:max_anystr_length: max length for str & byte types (default: ``2 ** 16``) +:min_number_size: min size for numbers (default: ``-2 ** 64``) +:max_number_size: max size for numbers (default: ``2 ** 64``) +:validate_all: whether or not to validate field defaults (default: ``False``) +:ignore_extra: whether to ignore any extra values in input data (default: ``True``) +:allow_extra: whether or not too allow (and include on the model) any extra values in input data (default: ``False``) +:allow_mutation: whether or not models are faux-immutable, e.g. __setattr__ fails (default: ``True``) +:fields: extra information on each field, currently just "alias" is allowed (default: ``None``) .. literalinclude:: examples/config.py @@ -191,6 +201,43 @@ The ellipsis notation ``...`` will not work with mypy, you need to use annotatio To get round this you can use the ``Required`` (via ``from pydantic import Required``) field as an alias for ellipses or annotation only. +Faux Immutability +................. + +Models can be configured to be immutable via ``allow_mutation = False`` this will prevent changing attributes of +a model. + +.. warning:: + + However be warned: immutability in python is never strict. If developers are determined/stupid they can always + modify a so called "immutable" object + +.. literalinclude:: examples/mutation.py + +Trying to change ``a`` caused an error and it remains unchanged, however the dict ``b`` is mutable and the +immutability of ``foobar`` doesn't stop being changed. + +Copy and Values +............... + +The ``values`` function returns a dict containing the attributes of a model sub-model are recursively +converted to dicts. + +While ``copy`` allows models to be duplicated, this is particularly useful for immutable models. + +Both ``values`` and ``copy`` take the optional ``include`` and ``exclude`` keyword arguments to control which attributes +are return/copied. ``copy`` allows an extra keyword argument ``update`` allowing attributes to be modified as the model +is duplicated. + +.. literalinclude:: examples/copy_values.py + +Pickle +...... + +Using the same plumbing as ``copy()`` pydantic models support efficient pickling and unpicking. + +.. literalinclude:: examples/ex_pickle.py + .. include:: ../HISTORY.rst diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 1f3c6b1..96a1acd 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -2,7 +2,11 @@ aren cerberus Config config +doesn +dicts django +faux +Faux ints jsonmodels pydantic diff --git a/pydantic/main.py b/pydantic/main.py index ab21c60..421eaa0 100644 --- a/pydantic/main.py +++ b/pydantic/main.py @@ -92,7 +92,7 @@ class BaseModel(metaclass=MetaModel): __slots__ = '__values__', def __init__(self, **data): - object.__setattr__(self, '__values__', self._process_values(data)) + self.__setstate__(self._process_values(data)) def __getattr__(self, name): try: @@ -107,6 +107,12 @@ class BaseModel(metaclass=MetaModel): raise TypeError(f'"{self.__class__.__name__}" is immutable and does not support item assignment') self.__values__[name] = value + def __getstate__(self): + return self.__values__ + + def __setstate__(self, state): + object.__setattr__(self, '__values__', state) + def values(self, *, include: Set[str]=None, exclude: Set[str]=set()) -> Dict[str, Any]: """ Get a dict of the values processed by the model, optionally specifying which fields to include or exclude. @@ -118,6 +124,37 @@ class BaseModel(metaclass=MetaModel): if k not in exclude and (not include or k in include) } + @classmethod + def construct(cls, **values): + """ + Creates a new model and set __values__ without any validation, thus values should be trusted + data. Chances are you don't want to use this method directly. + """ + m = cls.__new__(cls) + m.__setstate__(values) + return m + + def copy(self, *, include: Set[str]=None, exclude: Set[str]=None, update: Dict[str, Any]=None): + """ + Duplicate a model, optionally choose which fields to include, exclude and change. + + :param include: fields to include in new model + :param exclude: fields to exclude from new model, as with values this takes precedence over include + :param update: values to change/add in the new model. Note: the data is not validated before creating + the new model: you should trust this data + :return: new model instance + """ + if include is None and exclude is None and update is None: + # skip constructing values if no arguments are passed + v = self.__values__ + else: + exclude = exclude or set() + v = { + **{k: v for k, v in self.__values__.items() if k not in exclude and (not include or k in include)}, + **(update or {}) + } + return self.__class__.construct(**v) + @property def fields(self): return self.__fields__ diff --git a/pydantic/validators.py b/pydantic/validators.py index 7c6cac2..ae5ddfd 100644 --- a/pydantic/validators.py +++ b/pydantic/validators.py @@ -62,7 +62,7 @@ def number_size_validator(v, config, **kwargs): def anystr_length_validator(v, config, **kwargs): if v is None or config.min_anystr_length <= len(v) <= config.max_anystr_length: return v - raise ValueError(f'length not in range {config.max_anystr_length} to {config.max_anystr_length}') + raise ValueError(f'length not in range {config.min_anystr_length} to {config.max_anystr_length}') def ordered_dict_validator(v) -> OrderedDict: diff --git a/tests/test_construction.py b/tests/test_construction.py new file mode 100644 index 0000000..f333797 --- /dev/null +++ b/tests/test_construction.py @@ -0,0 +1,128 @@ +import pickle + +import pytest + +from pydantic import BaseModel + + +class Model(BaseModel): + a: float = ... + b: int = 10 + + +def test_simple_construct(): + m = Model.construct(a=40, b=10) + assert m.a == 40 + assert m.b == 10 + + +def test_construct_missing(): + m = Model.construct(a='not a float') + assert m.a == 'not a float' + with pytest.raises(AttributeError) as exc_info: + print(m.b) + + assert "'Model' object has no attribute 'b'" in str(exc_info) + + +def test_simple_copy(): + m = Model(a=24) + m2 = m.copy() + + assert m.a == m2.a == 24 + assert m.b == m2.b == 10 + assert m == m2 + assert m.__fields__ == m2.__fields__ + + +class ModelTwo(BaseModel): + a: float + b: int = 10 + c: str = 'foobar' + d: Model + + +def test_copy_exclude(): + m = ModelTwo(a=24, d=Model(a='12')) + m2 = m.copy(exclude={'b'}) + + assert m.a == m2.a == 24 + assert isinstance(m2.d, Model) + assert m2.d.a == 12 + + assert hasattr(m2, 'c') + assert not hasattr(m2, 'b') + assert set(m.values().keys()) == {'a', 'b', 'c', 'd'} + assert set(m2.values().keys()) == {'a', 'c', 'd'} + + assert m != m2 + + +def test_copy_include(): + m = ModelTwo(a=24, d=Model(a='12')) + m2 = m.copy(include={'a'}) + + assert m.a == m2.a == 24 + assert set(m.values().keys()) == {'a', 'b', 'c', 'd'} + assert set(m2.values().keys()) == {'a'} + + assert m != m2 + + +def test_copy_include_exclude(): + m = ModelTwo(a=24, d=Model(a='12')) + m2 = m.copy(include={'a', 'b', 'c'}, exclude={'c'}) + + assert set(m.values().keys()) == {'a', 'b', 'c', 'd'} + assert set(m2.values().keys()) == {'a', 'b'} + + +def test_copy_update(): + m = ModelTwo(a=24, d=Model(a='12')) + m2 = m.copy(update={'a': 'different'}) + + assert m.a == 24 + assert m2.a == 'different' + assert set(m.values().keys()) == set(m2.values().keys()) == {'a', 'b', 'c', 'd'} + + assert m != m2 + + +def test_simple_pickle(): + m = Model(a='24') + b = pickle.dumps(m) + m2 = pickle.loads(b) + assert m.a == m2.a == 24 + assert m.b == m2.b == 10 + assert m == m2 + assert m is not m2 + assert tuple(m) == (('a', 24.0), ('b', 10)) + assert tuple(m2) == (('a', 24.0), ('b', 10)) + assert m.__fields__ == m2.__fields__ + + +def test_recursive_pickle(): + m = ModelTwo(a=24, d=Model(a='123.45')) + m2 = pickle.loads(pickle.dumps(m)) + assert m == m2 + + assert m.d.a == 123.45 + assert m2.d.a == 123.45 + assert m.__fields__ == m2.__fields__ + + +def test_immutable_copy(): + class Model(BaseModel): + a: int + b: int + + class Config: + allow_mutation = False + + m = Model(a=40, b=10) + assert m == m.copy() + + m2 = m.copy(update={'b': 12}) + assert str(m2) == 'Model a=40 b=12' + with pytest.raises(TypeError): + m2.b = 13