Skip to content

Commit

Permalink
feature: generate a hash function when frozen is True
Browse files Browse the repository at this point in the history
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`)
  • Loading branch information
rhuille committed Jan 4, 2021
1 parent 75d5227 commit 45da2e7
Show file tree
Hide file tree
Showing 4 changed files with 76 additions and 0 deletions.
1 change: 1 addition & 0 deletions 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`)
7 changes: 7 additions & 0 deletions docs/usage/model_config.md
Expand Up @@ -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`)
Expand Down
8 changes: 8 additions & 0 deletions pydantic/main.py
Expand Up @@ -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
Expand Down Expand Up @@ -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},
}

Expand Down
60 changes: 60 additions & 0 deletions tests/test_main.py
Expand Up @@ -383,6 +383,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)
Expand Down

0 comments on commit 45da2e7

Please sign in to comment.