mirror of
https://github.com/kennethreitz/pydantic.git
synced 2026-06-05 23:00:18 +00:00
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:
@@ -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
@@ -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)
|
||||
"""
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ aren
|
||||
cerberus
|
||||
Config
|
||||
config
|
||||
doesn
|
||||
dicts
|
||||
django
|
||||
faux
|
||||
Faux
|
||||
ints
|
||||
jsonmodels
|
||||
pydantic
|
||||
|
||||
+38
-1
@@ -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__
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user