Repository: pydantic/pydantic

Issue:
Generate a hash function when `allow_mutation` is `False`
# Feature Request

Output of `python -c "import pydantic.utils; print(pydantic.utils.version_info())"`:
```
             pydantic version: 1.6.1
            pydantic compiled: False
                 install path: /home/raphael/rhuille/pydantic/pydantic
               python version: 3.8.5 (default, Aug 18 2020, 10:43:36)  [GCC 7.5.0]
                     platform: Linux-5.0.0-1067-oem-osp1-x86_64-with-glibc2.27
     optional deps. installed: ['typing-extensions', 'email-validator', 'devtools']

```
Hi there,

I need the model to be hashable when I set `allow_mutation` to `False`. I could write myself a `__hash__` function, but I think it could be nice if pydantic generated it by default in the class `BaseModel`.

This would be the same behavior of the built-in `dataclass` which generate a hash function if the object is immutable (parameter `frozen` is `True`) and the `__eq__` function exists. 

Quoting the documentation https://docs.python.org/3/library/dataclasses.html :
```
If eq and frozen are both true, by default dataclass() will generate a __hash__() method for you.
```

We could generate a default `__hash__` function for `BaseModel` if `allow_mutation` is `False`, what do you think ?


### Examples

For example, now we have:
```py
from pydantic import BaseModel

class A(BaseModel):
    x: int
    
    class Config:
        allow_mutation = False

a = A(x=1)
d = {a: 2}

>>> TypeError: unhashable type: 'A'
```

After, we would have:
```py
from pydantic import BaseModel

class A(BaseModel):
    x: int
    
    class Config:
        allow_mutation = False

a = A(x=1)
d = {a: 2}
d[a]
>>> 2
```

(Note that if `allow_mutation = True`, the hash function would be `None` and the behavior would not be changed)


Discussion:

Resolving patch:
diff --git a/pydantic/main.py b/pydantic/main.py
--- a/pydantic/main.py
+++ b/pydantic/main.py
@@ -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 @@ def is_untouched(v: Any) -> bool:
             '__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 @@ def __setattr__(self, name, value):  # noqa: C901 (ignore complexity)
 
         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}
diff --git a/pydantic/mypy.py b/pydantic/mypy.py
--- a/pydantic/mypy.py
+++ b/pydantic/mypy.py
@@ -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 @@ def transform(self) -> None:
         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 @@ def transform(self) -> None:
         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 @@ def __init__(
         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


Test patch:
diff --git a/tests/mypy/modules/plugin_fail.py b/tests/mypy/modules/plugin_fail.py
--- a/tests/mypy/modules/plugin_fail.py
+++ b/tests/mypy/modules/plugin_fail.py
@@ -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'
diff --git a/tests/mypy/modules/plugin_success.py b/tests/mypy/modules/plugin_success.py
--- a/tests/mypy/modules/plugin_success.py
+++ b/tests/mypy/modules/plugin_success.py
@@ -139,3 +139,22 @@ class Model(BaseModel):
 
 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)
diff --git a/tests/mypy/outputs/plugin-fail-strict.txt b/tests/mypy/outputs/plugin-fail-strict.txt
--- a/tests/mypy/outputs/plugin-fail-strict.txt
+++ b/tests/mypy/outputs/plugin-fail-strict.txt
@@ -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>
\ No newline at end of file
+197: note:     <1 more non-matching overload not shown>
+219: error: Property "y" defined in "FrozenModel" is read-only  [misc]
\ No newline at end of file
diff --git a/tests/mypy/outputs/plugin-fail.txt b/tests/mypy/outputs/plugin-fail.txt
--- a/tests/mypy/outputs/plugin-fail.txt
+++ b/tests/mypy/outputs/plugin-fail.txt
@@ -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>
\ No newline at end of file
+197: note:     <1 more non-matching overload not shown>
+219: error: Property "y" defined in "FrozenModel" is read-only  [misc]
\ No newline at end of file
diff --git a/tests/test_construction.py b/tests/test_construction.py
--- a/tests/test_construction.py
+++ b/tests/test_construction.py
@@ -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 @@ class Config:
         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}
diff --git a/tests/test_main.py b/tests/test_main.py
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -351,39 +351,98 @@ class Model(BaseModel):
     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():