Immutability part 2 (#53)

* add construct method, fix #48
* adding copy method
* adding pickle support, fix #40
* tweak copy and add fields copy test
* adding docs for immutability, values and copy
* add docs for pickle
This commit is contained in:
Samuel Colvin
2017-06-21 18:15:08 +01:00
committed by GitHub
parent 53ba356158
commit 92d7689271
10 changed files with 319 additions and 15 deletions
+2
View File
@@ -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
+15 -12
View File
@@ -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)
"""
+35
View File
@@ -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=<BarModel whatever=123>
print(m.copy(include={'foo', 'bar'}))
# > FooBarModel foo='hello' bar=<BarModel whatever=123>
print(m.copy(exclude={'foo', 'bar'}))
# > FooBarModel banana=3.14
print(m.copy(update={'banana': 0}))
# > FooBarModel banana=0 foo='hello' bar=<BarModel whatever=123>
+20
View File
@@ -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
+28
View File
@@ -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'}
+48 -1
View File
@@ -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
+4
View File
@@ -2,7 +2,11 @@ aren
cerberus
Config
config
doesn
dicts
django
faux
Faux
ints
jsonmodels
pydantic
+38 -1
View File
@@ -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__
+1 -1
View File
@@ -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:
+128
View File
@@ -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