diff --git a/changes/1880-rhuille.md b/changes/1880-rhuille.md new file mode 100644 index 00000000000..46a87f16ead --- /dev/null +++ b/changes/1880-rhuille.md @@ -0,0 +1 @@ +Add a new `frozen` boolean parameters. Setting `frozen=True` does everything that `allow_mutation=False` does, and 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`) diff --git a/docs/usage/model_config.md b/docs/usage/model_config.md index f847ad8c044..df8a69bbaae 100644 --- a/docs/usage/model_config.md +++ b/docs/usage/model_config.md @@ -26,6 +26,13 @@ Options: **`allow_mutation`** : whether or not models are faux-immutable, i.e. whether `__setattr__` is allowed (default: `True`) +**`frozen`** + +!!! warning + This parameters is beta + +: setting `frozen=True` does everything that `allow_mutation=False` does, and 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`) + **`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`) diff --git a/pydantic/main.py b/pydantic/main.py index 39437de4947..b2c3280e843 100644 --- a/pydantic/main.py +++ b/pydantic/main.py @@ -199,6 +199,13 @@ def validate_custom_root_type(fields: Dict[str, ModelField]) -> None: raise ValueError('__root__ cannot be mixed with other fields') +def generate_hash_function(frozen, super_class): + def hash_function(self_: Any) -> int: + return hash(super_class) + hash(tuple(self_.__dict__.values())) + + return hash_function if frozen else None + + UNTOUCHED_TYPES = FunctionType, property, type, classmethod, staticmethod # Note `ModelMetaclass` refers to `BaseModel`, but is also used to *create* `BaseModel`, so we need to add this extra @@ -322,6 +329,7 @@ def __new__(mcs, name, bases, namespace, **kwargs): # noqa C901 '__custom_root_type__': _custom_root_type, '__private_attributes__': private_attributes, '__slots__': slots | private_attributes.keys(), + '__hash__': generate_hash_function(config.frozen, super().__class__()), **{n: v for n, v in namespace.items() if n not in exclude_from_namespace}, } diff --git a/tests/test_main.py b/tests/test_main.py index 84d8c2142d1..8675226c3f8 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -388,6 +388,66 @@ class Config: 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(): class Model(BaseModel): a: int = Field(3, const=True)