mirror of
https://github.com/kennethreitz/pydantic.git
synced 2026-06-05 23:00:18 +00:00
Generate a hash function when frozen is True (#1881)
* feature: add a `frozen` parameter to config For now, `frozen` is a strict duplication of `allow_mutation` parameter i.e. setting `frozen=True` does everything that `allow_mutation=False` does. NB: this does not change the behavior of `allow_mutation`. In next commit, setting `frozen=True` will also make the BaseModel hashable while the existing behavior of `allow_mutation` will not be updated. * refactor: factorise immutability tests * feature: generate a hash function when frozen is True Now, setting `frozen=True` also generate a hash function for the model i.e. `__hash__` is not `None`. This makes instances of the model potentially hashable if all the attributes are hashable. (default: `False`) * reviewer feedback: use hash of the class instead of the super * reviewer feedback: fix spelling checks * reviewer feedback: update changes description * test: remwork mypy tests in order to catch only frozen related errors Before: there were errors about other stuff than frozen behavior After: The modification catch only errot related to the frozen behavior * test: split test_immutablity in 2 functions One function tests the behavior: 'the model is mutable' The other tests the behavior:OC 'the model is immutable' * test mutability: remove the unnecessary parametrization * test immutability: remove assertion that do not test frozen behavior
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
Add a new `frozen` boolean parameter to `Config` (default: `False`).
|
||||
Setting `frozen=True` does everything that `allow_mutation=False` does, and also generates a `__hash__()` method for the model. This makes instances of the model potentially hashable if all the attributes are hashable.
|
||||
@@ -29,6 +29,14 @@ Options:
|
||||
**`allow_mutation`**
|
||||
: whether or not models are faux-immutable, i.e. whether `__setattr__` is allowed (default: `True`)
|
||||
|
||||
**`frozen`**
|
||||
|
||||
!!! warning
|
||||
This parameter is in beta
|
||||
|
||||
: setting `frozen=True` does everything that `allow_mutation=False` does, and also generates a `__hash__()` method for the model. This makes instances of the model potentially hashable if all the attributes are hashable. (default: `False`)
|
||||
|
||||
|
||||
**`use_enum_values`**
|
||||
: whether to populate models with the `value` property of enums, rather than the raw enum.
|
||||
This may be useful if you want to serialise `model.dict()` later (default: `False`)
|
||||
|
||||
+10
-3
@@ -120,6 +120,7 @@ class BaseConfig:
|
||||
validate_all = False
|
||||
extra = Extra.ignore
|
||||
allow_mutation = True
|
||||
frozen = False
|
||||
allow_population_by_field_name = False
|
||||
use_enum_values = False
|
||||
fields: Dict[str, Union[str, Dict[str, str]]] = {}
|
||||
@@ -215,12 +216,17 @@ def validate_custom_root_type(fields: Dict[str, ModelField]) -> None:
|
||||
raise ValueError(f'{ROOT_KEY} cannot be mixed with other fields')
|
||||
|
||||
|
||||
# Annotated fields can have many types like `str`, `int`, `List[str]`, `Callable`...
|
||||
def generate_hash_function(frozen: bool) -> Optional[Callable[[Any], int]]:
|
||||
def hash_function(self_: Any) -> int:
|
||||
return hash(self_.__class__) + hash(tuple(self_.__dict__.values()))
|
||||
|
||||
return hash_function if frozen else None
|
||||
|
||||
|
||||
# If a field is of type `Callable`, its default value should be a function and cannot to ignored.
|
||||
ANNOTATED_FIELD_UNTOUCHED_TYPES: Tuple[Any, ...] = (property, type, classmethod, staticmethod)
|
||||
# When creating a `BaseModel` instance, we bypass all the methods, properties... added to the model
|
||||
UNTOUCHED_TYPES: Tuple[Any, ...] = (FunctionType,) + ANNOTATED_FIELD_UNTOUCHED_TYPES
|
||||
|
||||
# Note `ModelMetaclass` refers to `BaseModel`, but is also used to *create* `BaseModel`, so we need to add this extra
|
||||
# (somewhat hacky) boolean to keep track of whether we've created the `BaseModel` class yet, and therefore whether it's
|
||||
# safe to refer to it. If it *hasn't* been created, we assume that the `__new__` call we're in the middle of is for
|
||||
@@ -353,6 +359,7 @@ class ModelMetaclass(ABCMeta):
|
||||
'__custom_root_type__': _custom_root_type,
|
||||
'__private_attributes__': private_attributes,
|
||||
'__slots__': slots | private_attributes.keys(),
|
||||
'__hash__': generate_hash_function(config.frozen),
|
||||
'__class_vars__': class_vars,
|
||||
**{n: v for n, v in namespace.items() if n not in exclude_from_namespace},
|
||||
}
|
||||
@@ -413,7 +420,7 @@ class BaseModel(Representation, metaclass=ModelMetaclass):
|
||||
|
||||
if self.__config__.extra is not Extra.allow and name not in self.__fields__:
|
||||
raise ValueError(f'"{self.__class__.__name__}" object has no field "{name}"')
|
||||
elif not self.__config__.allow_mutation:
|
||||
elif not self.__config__.allow_mutation or self.__config__.frozen:
|
||||
raise TypeError(f'"{self.__class__.__name__}" is immutable and does not support item assignment')
|
||||
elif self.__config__.validate_assignment:
|
||||
new_values = {**self.__dict__, name: value}
|
||||
|
||||
+5
-2
@@ -143,6 +143,7 @@ class PydanticModelTransformer:
|
||||
tracked_config_fields: Set[str] = {
|
||||
'extra',
|
||||
'allow_mutation',
|
||||
'frozen',
|
||||
'orm_mode',
|
||||
'allow_population_by_field_name',
|
||||
'alias_generator',
|
||||
@@ -159,7 +160,7 @@ class PydanticModelTransformer:
|
||||
In particular:
|
||||
* determines the model config and fields,
|
||||
* adds a fields-aware signature for the initializer and construct methods
|
||||
* freezes the class if allow_mutation = False
|
||||
* freezes the class if allow_mutation = False or frozen = True
|
||||
* stores the fields, config, and if the class is settings in the mypy metadata for access by subclasses
|
||||
"""
|
||||
ctx = self._ctx
|
||||
@@ -174,7 +175,7 @@ class PydanticModelTransformer:
|
||||
is_settings = any(get_fullname(base) == BASESETTINGS_FULLNAME for base in info.mro[:-1])
|
||||
self.add_initializer(fields, config, is_settings)
|
||||
self.add_construct_method(fields)
|
||||
self.set_frozen(fields, frozen=config.allow_mutation is False)
|
||||
self.set_frozen(fields, frozen=config.allow_mutation is False or config.frozen is True)
|
||||
info.metadata[METADATA_KEY] = {
|
||||
'fields': {field.name: field.serialize() for field in fields},
|
||||
'config': config.set_values_dict(),
|
||||
@@ -529,12 +530,14 @@ class ModelConfigData:
|
||||
self,
|
||||
forbid_extra: Optional[bool] = None,
|
||||
allow_mutation: Optional[bool] = None,
|
||||
frozen: Optional[bool] = None,
|
||||
orm_mode: Optional[bool] = None,
|
||||
allow_population_by_field_name: Optional[bool] = None,
|
||||
has_alias_generator: Optional[bool] = None,
|
||||
):
|
||||
self.forbid_extra = forbid_extra
|
||||
self.allow_mutation = allow_mutation
|
||||
self.frozen = frozen
|
||||
self.orm_mode = orm_mode
|
||||
self.allow_population_by_field_name = allow_population_by_field_name
|
||||
self.has_alias_generator = has_alias_generator
|
||||
|
||||
@@ -202,3 +202,27 @@ class AddProject:
|
||||
|
||||
|
||||
p = AddProject(name='x', slug='y', description='z')
|
||||
|
||||
|
||||
# Same as Model, but with frozen = True
|
||||
class FrozenModel(BaseModel):
|
||||
x: int
|
||||
y: str
|
||||
|
||||
class Config:
|
||||
alias_generator = None
|
||||
frozen = True
|
||||
extra = Extra.forbid
|
||||
|
||||
|
||||
frozenmodel = FrozenModel(x=1, y='b')
|
||||
frozenmodel.y = 'a'
|
||||
|
||||
|
||||
class InheritingModel2(FrozenModel):
|
||||
class Config:
|
||||
frozen = False
|
||||
|
||||
|
||||
inheriting2 = InheritingModel2(x=1, y='c')
|
||||
inheriting2.y = 'd'
|
||||
|
||||
@@ -139,3 +139,22 @@ DynamicModel = create_model('DynamicModel', __base__=Model)
|
||||
|
||||
dynamic_model = DynamicModel(x=1, y='y')
|
||||
dynamic_model.x = 2
|
||||
|
||||
|
||||
class FrozenModel(BaseModel):
|
||||
x: int
|
||||
|
||||
class Config:
|
||||
frozen = True
|
||||
|
||||
|
||||
class NotFrozenModel(FrozenModel):
|
||||
a: int = 1
|
||||
|
||||
class Config:
|
||||
frozen = False
|
||||
orm_mode = True
|
||||
|
||||
|
||||
NotFrozenModel(x=1).x = 2
|
||||
NotFrozenModel.from_orm(model)
|
||||
|
||||
@@ -35,4 +35,5 @@
|
||||
197: error: No overload variant of "dataclass" matches argument type "Dict[<nothing>, <nothing>]" [call-overload]
|
||||
197: note: Possible overload variant:
|
||||
197: note: def dataclass(*, init: bool = ..., repr: bool = ..., eq: bool = ..., order: bool = ..., unsafe_hash: bool = ..., frozen: bool = ..., config: Optional[Type[Any]] = ...) -> Callable[[Type[Any]], Type[Dataclass]]
|
||||
197: note: <1 more non-matching overload not shown>
|
||||
197: note: <1 more non-matching overload not shown>
|
||||
219: error: Property "y" defined in "FrozenModel" is read-only [misc]
|
||||
@@ -24,4 +24,5 @@
|
||||
197: error: No overload variant of "dataclass" matches argument type "Dict[<nothing>, <nothing>]" [call-overload]
|
||||
197: note: Possible overload variant:
|
||||
197: note: def dataclass(*, init: bool = ..., repr: bool = ..., eq: bool = ..., order: bool = ..., unsafe_hash: bool = ..., frozen: bool = ..., config: Optional[Type[Any]] = ...) -> Callable[[Type[Any]], Type[Dataclass]]
|
||||
197: note: <1 more non-matching overload not shown>
|
||||
197: note: <1 more non-matching overload not shown>
|
||||
219: error: Property "y" defined in "FrozenModel" is read-only [misc]
|
||||
@@ -253,7 +253,7 @@ def test_recursive_pickle():
|
||||
assert m.__foo__ == m2.__foo__
|
||||
|
||||
|
||||
def test_immutable_copy():
|
||||
def test_immutable_copy_with_allow_mutation():
|
||||
class Model(BaseModel):
|
||||
a: int
|
||||
b: int
|
||||
@@ -270,6 +270,23 @@ def test_immutable_copy():
|
||||
m2.b = 13
|
||||
|
||||
|
||||
def test_immutable_copy_with_frozen():
|
||||
class Model(BaseModel):
|
||||
a: int
|
||||
b: int
|
||||
|
||||
class Config:
|
||||
frozen = True
|
||||
|
||||
m = Model(a=40, b=10)
|
||||
assert m == m.copy()
|
||||
|
||||
m2 = m.copy(update={'b': 12})
|
||||
assert repr(m2) == 'Model(a=40, b=12)'
|
||||
with pytest.raises(TypeError):
|
||||
m2.b = 13
|
||||
|
||||
|
||||
def test_pickle_fields_set():
|
||||
m = Model(a=24)
|
||||
assert m.dict(exclude_unset=True) == {'a': 24}
|
||||
|
||||
+68
-9
@@ -351,39 +351,98 @@ def test_required():
|
||||
assert exc_info.value.errors() == [{'loc': ('a',), 'msg': 'field required', 'type': 'value_error.missing'}]
|
||||
|
||||
|
||||
def test_not_immutability():
|
||||
def test_mutability():
|
||||
class TestModel(BaseModel):
|
||||
a: int = 10
|
||||
|
||||
class Config:
|
||||
allow_mutation = True
|
||||
extra = Extra.forbid
|
||||
frozen = False
|
||||
|
||||
m = TestModel()
|
||||
|
||||
assert m.a == 10
|
||||
m.a = 11
|
||||
assert m.a == 11
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
m.b = 11
|
||||
assert '"TestModel" object has no field "b"' in exc_info.value.args[0]
|
||||
|
||||
|
||||
def test_immutability():
|
||||
@pytest.mark.parametrize('allow_mutation_, frozen_', [(False, False), (False, True), (True, True)])
|
||||
def test_immutability(allow_mutation_, frozen_):
|
||||
class TestModel(BaseModel):
|
||||
a: int = 10
|
||||
|
||||
class Config:
|
||||
allow_mutation = False
|
||||
allow_mutation = allow_mutation_
|
||||
extra = Extra.forbid
|
||||
frozen = frozen_
|
||||
|
||||
m = TestModel()
|
||||
|
||||
assert m.a == 10
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
m.a = 11
|
||||
assert '"TestModel" is immutable and does not support item assignment' in exc_info.value.args[0]
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
m.b = 11
|
||||
assert '"TestModel" object has no field "b"' in exc_info.value.args[0]
|
||||
|
||||
|
||||
def test_not_frozen_are_not_hashable():
|
||||
class TestModel(BaseModel):
|
||||
a: int = 10
|
||||
|
||||
m = TestModel()
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
hash(m)
|
||||
assert "unhashable type: 'TestModel'" in exc_info.value.args[0]
|
||||
|
||||
|
||||
def test_frozen_with_hashable_fields_are_hashable():
|
||||
class TestModel(BaseModel):
|
||||
a: int = 10
|
||||
|
||||
class Config:
|
||||
frozen = True
|
||||
|
||||
m = TestModel()
|
||||
assert m.__hash__ is not None
|
||||
assert isinstance(hash(m), int)
|
||||
|
||||
|
||||
def test_frozen_with_unhashable_fields_are_not_hashable():
|
||||
class TestModel(BaseModel):
|
||||
a: int = 10
|
||||
y: List[int] = [1, 2, 3]
|
||||
|
||||
class Config:
|
||||
frozen = True
|
||||
|
||||
m = TestModel()
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
hash(m)
|
||||
assert "unhashable type: 'list'" in exc_info.value.args[0]
|
||||
|
||||
|
||||
def test_hash_function_give_different_result_for_different_object():
|
||||
class TestModel(BaseModel):
|
||||
a: int = 10
|
||||
|
||||
class Config:
|
||||
frozen = True
|
||||
|
||||
m = TestModel()
|
||||
m2 = TestModel()
|
||||
m3 = TestModel(a=11)
|
||||
assert hash(m) == hash(m2)
|
||||
assert hash(m) != hash(m3)
|
||||
|
||||
# Redefined `TestModel`
|
||||
class TestModel(BaseModel):
|
||||
a: int = 10
|
||||
|
||||
class Config:
|
||||
frozen = True
|
||||
|
||||
m4 = TestModel()
|
||||
assert hash(m) != hash(m4)
|
||||
|
||||
|
||||
def test_const_validates():
|
||||
|
||||
Reference in New Issue
Block a user