bpo-32929: Dataclasses: Change the tri-state hash parameter to the boolean unsafe_hash. (#5891)
unsafe_hash=False is now the default. It is the same behavior as the old hash=None parameter. unsafe_hash=True will try to add __hash__. If it already exists, TypeError is raised.
This commit is contained in:
parent
9c17e3a198
commit
dbf9cff48a
|
@ -20,11 +20,11 @@ __all__ = ['dataclass',
|
||||||
|
|
||||||
# Conditions for adding methods. The boxes indicate what action the
|
# Conditions for adding methods. The boxes indicate what action the
|
||||||
# dataclass decorator takes. For all of these tables, when I talk
|
# dataclass decorator takes. For all of these tables, when I talk
|
||||||
# about init=, repr=, eq=, order=, hash=, or frozen=, I'm referring
|
# about init=, repr=, eq=, order=, unsafe_hash=, or frozen=, I'm
|
||||||
# to the arguments to the @dataclass decorator. When checking if a
|
# referring to the arguments to the @dataclass decorator. When
|
||||||
# dunder method already exists, I mean check for an entry in the
|
# checking if a dunder method already exists, I mean check for an
|
||||||
# class's __dict__. I never check to see if an attribute is defined
|
# entry in the class's __dict__. I never check to see if an
|
||||||
# in a base class.
|
# attribute is defined in a base class.
|
||||||
|
|
||||||
# Key:
|
# Key:
|
||||||
# +=========+=========================================+
|
# +=========+=========================================+
|
||||||
|
@ -34,11 +34,6 @@ __all__ = ['dataclass',
|
||||||
# +---------+-----------------------------------------+
|
# +---------+-----------------------------------------+
|
||||||
# | add | Generated method is added. |
|
# | add | Generated method is added. |
|
||||||
# +---------+-----------------------------------------+
|
# +---------+-----------------------------------------+
|
||||||
# | add* | Generated method is added only if the |
|
|
||||||
# | | existing attribute is None and if the |
|
|
||||||
# | | user supplied a __eq__ method in the |
|
|
||||||
# | | class definition. |
|
|
||||||
# +---------+-----------------------------------------+
|
|
||||||
# | raise | TypeError is raised. |
|
# | raise | TypeError is raised. |
|
||||||
# +---------+-----------------------------------------+
|
# +---------+-----------------------------------------+
|
||||||
# | None | Attribute is set to None. |
|
# | None | Attribute is set to None. |
|
||||||
|
@ -115,43 +110,36 @@ __all__ = ['dataclass',
|
||||||
|
|
||||||
# __hash__
|
# __hash__
|
||||||
|
|
||||||
# +------------------- hash= parameter
|
# +------------------- unsafe_hash= parameter
|
||||||
# | +----------- eq= parameter
|
# | +----------- eq= parameter
|
||||||
# | | +--- frozen= parameter
|
# | | +--- frozen= parameter
|
||||||
# | | |
|
# | | |
|
||||||
# v v v | | |
|
# v v v | | |
|
||||||
# | no | yes | <--- class has __hash__ in __dict__?
|
# | no | yes | <--- class has explicitly defined __hash__
|
||||||
# +=========+=======+=======+========+========+
|
# +=======+=======+=======+========+========+
|
||||||
# | 1 None | False | False | | | No __eq__, use the base class __hash__
|
# | False | False | False | | | No __eq__, use the base class __hash__
|
||||||
# +---------+-------+-------+--------+--------+
|
# +-------+-------+-------+--------+--------+
|
||||||
# | 2 None | False | True | | | No __eq__, use the base class __hash__
|
# | False | False | True | | | No __eq__, use the base class __hash__
|
||||||
# +---------+-------+-------+--------+--------+
|
# +-------+-------+-------+--------+--------+
|
||||||
# | 3 None | True | False | None | | <-- the default, not hashable
|
# | False | True | False | None | | <-- the default, not hashable
|
||||||
# +---------+-------+-------+--------+--------+
|
# +-------+-------+-------+--------+--------+
|
||||||
# | 4 None | True | True | add | add* | Frozen, so hashable
|
# | False | True | True | add | | Frozen, so hashable, allows override
|
||||||
# +---------+-------+-------+--------+--------+
|
# +-------+-------+-------+--------+--------+
|
||||||
# | 5 False | False | False | | |
|
# | True | False | False | add | raise | Has no __eq__, but hashable
|
||||||
# +---------+-------+-------+--------+--------+
|
# +-------+-------+-------+--------+--------+
|
||||||
# | 6 False | False | True | | |
|
# | True | False | True | add | raise | Has no __eq__, but hashable
|
||||||
# +---------+-------+-------+--------+--------+
|
# +-------+-------+-------+--------+--------+
|
||||||
# | 7 False | True | False | | |
|
# | True | True | False | add | raise | Not frozen, but hashable
|
||||||
# +---------+-------+-------+--------+--------+
|
# +-------+-------+-------+--------+--------+
|
||||||
# | 8 False | True | True | | |
|
# | True | True | True | add | raise | Frozen, so hashable
|
||||||
# +---------+-------+-------+--------+--------+
|
# +=======+=======+=======+========+========+
|
||||||
# | 9 True | False | False | add | add* | Has no __eq__, but hashable
|
|
||||||
# +---------+-------+-------+--------+--------+
|
|
||||||
# |10 True | False | True | add | add* | Has no __eq__, but hashable
|
|
||||||
# +---------+-------+-------+--------+--------+
|
|
||||||
# |11 True | True | False | add | add* | Not frozen, but hashable
|
|
||||||
# +---------+-------+-------+--------+--------+
|
|
||||||
# |12 True | True | True | add | add* | Frozen, so hashable
|
|
||||||
# +=========+=======+=======+========+========+
|
|
||||||
# For boxes that are blank, __hash__ is untouched and therefore
|
# For boxes that are blank, __hash__ is untouched and therefore
|
||||||
# inherited from the base class. If the base is object, then
|
# inherited from the base class. If the base is object, then
|
||||||
# id-based hashing is used.
|
# id-based hashing is used.
|
||||||
# Note that a class may have already __hash__=None if it specified an
|
# Note that a class may have already __hash__=None if it specified an
|
||||||
# __eq__ method in the class body (not one that was created by
|
# __eq__ method in the class body (not one that was created by
|
||||||
# @dataclass).
|
# @dataclass).
|
||||||
|
# See _hash_action (below) for a coded version of this table.
|
||||||
|
|
||||||
|
|
||||||
# Raised when an attempt is made to modify a frozen class.
|
# Raised when an attempt is made to modify a frozen class.
|
||||||
|
@ -557,7 +545,45 @@ def _set_new_attribute(cls, name, value):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _process_class(cls, repr, eq, order, hash, init, frozen):
|
# Decide if/how we're going to create a hash function. Key is
|
||||||
|
# (unsafe_hash, eq, frozen, does-hash-exist). Value is the action to
|
||||||
|
# take.
|
||||||
|
# Actions:
|
||||||
|
# '': Do nothing.
|
||||||
|
# 'none': Set __hash__ to None.
|
||||||
|
# 'add': Always add a generated __hash__function.
|
||||||
|
# 'exception': Raise an exception.
|
||||||
|
#
|
||||||
|
# +-------------------------------------- unsafe_hash?
|
||||||
|
# | +------------------------------- eq?
|
||||||
|
# | | +------------------------ frozen?
|
||||||
|
# | | | +---------------- has-explicit-hash?
|
||||||
|
# | | | |
|
||||||
|
# | | | | +------- action
|
||||||
|
# | | | | |
|
||||||
|
# v v v v v
|
||||||
|
_hash_action = {(False, False, False, False): (''),
|
||||||
|
(False, False, False, True ): (''),
|
||||||
|
(False, False, True, False): (''),
|
||||||
|
(False, False, True, True ): (''),
|
||||||
|
(False, True, False, False): ('none'),
|
||||||
|
(False, True, False, True ): (''),
|
||||||
|
(False, True, True, False): ('add'),
|
||||||
|
(False, True, True, True ): (''),
|
||||||
|
(True, False, False, False): ('add'),
|
||||||
|
(True, False, False, True ): ('exception'),
|
||||||
|
(True, False, True, False): ('add'),
|
||||||
|
(True, False, True, True ): ('exception'),
|
||||||
|
(True, True, False, False): ('add'),
|
||||||
|
(True, True, False, True ): ('exception'),
|
||||||
|
(True, True, True, False): ('add'),
|
||||||
|
(True, True, True, True ): ('exception'),
|
||||||
|
}
|
||||||
|
# See https://bugs.python.org/issue32929#msg312829 for an if-statement
|
||||||
|
# version of this table.
|
||||||
|
|
||||||
|
|
||||||
|
def _process_class(cls, repr, eq, order, unsafe_hash, init, frozen):
|
||||||
# Now that dicts retain insertion order, there's no reason to use
|
# Now that dicts retain insertion order, there's no reason to use
|
||||||
# an ordered dict. I am leveraging that ordering here, because
|
# an ordered dict. I am leveraging that ordering here, because
|
||||||
# derived class fields overwrite base class fields, but the order
|
# derived class fields overwrite base class fields, but the order
|
||||||
|
@ -605,8 +631,14 @@ def _process_class(cls, repr, eq, order, hash, init, frozen):
|
||||||
# be inherited down.
|
# be inherited down.
|
||||||
is_frozen = frozen or cls.__setattr__ is _frozen_setattr
|
is_frozen = frozen or cls.__setattr__ is _frozen_setattr
|
||||||
|
|
||||||
# Was this class defined with an __eq__? Used in __hash__ logic.
|
# Was this class defined with an explicit __hash__? Note that if
|
||||||
auto_hash_test= '__eq__' in cls.__dict__ and getattr(cls.__dict__, '__hash__', MISSING) is None
|
# __eq__ is defined in this class, then python will automatically
|
||||||
|
# set __hash__ to None. This is a heuristic, as it's possible
|
||||||
|
# that such a __hash__ == None was not auto-generated, but it
|
||||||
|
# close enough.
|
||||||
|
class_hash = cls.__dict__.get('__hash__', MISSING)
|
||||||
|
has_explicit_hash = not (class_hash is MISSING or
|
||||||
|
(class_hash is None and '__eq__' in cls.__dict__))
|
||||||
|
|
||||||
# If we're generating ordering methods, we must be generating
|
# If we're generating ordering methods, we must be generating
|
||||||
# the eq methods.
|
# the eq methods.
|
||||||
|
@ -661,7 +693,7 @@ def _process_class(cls, repr, eq, order, hash, init, frozen):
|
||||||
if _set_new_attribute(cls, name,
|
if _set_new_attribute(cls, name,
|
||||||
_cmp_fn(name, op, self_tuple, other_tuple)):
|
_cmp_fn(name, op, self_tuple, other_tuple)):
|
||||||
raise TypeError(f'Cannot overwrite attribute {name} '
|
raise TypeError(f'Cannot overwrite attribute {name} '
|
||||||
f'in {cls.__name__}. Consider using '
|
f'in class {cls.__name__}. Consider using '
|
||||||
'functools.total_ordering')
|
'functools.total_ordering')
|
||||||
|
|
||||||
if is_frozen:
|
if is_frozen:
|
||||||
|
@ -669,40 +701,30 @@ def _process_class(cls, repr, eq, order, hash, init, frozen):
|
||||||
('__delattr__', _frozen_delattr)]:
|
('__delattr__', _frozen_delattr)]:
|
||||||
if _set_new_attribute(cls, name, fn):
|
if _set_new_attribute(cls, name, fn):
|
||||||
raise TypeError(f'Cannot overwrite attribute {name} '
|
raise TypeError(f'Cannot overwrite attribute {name} '
|
||||||
f'in {cls.__name__}')
|
f'in class {cls.__name__}')
|
||||||
|
|
||||||
# Decide if/how we're going to create a hash function.
|
# Decide if/how we're going to create a hash function.
|
||||||
# TODO: Move this table to module scope, so it's not recreated
|
hash_action = _hash_action[bool(unsafe_hash),
|
||||||
# all the time.
|
bool(eq),
|
||||||
generate_hash = {(None, False, False): ('', ''),
|
bool(frozen),
|
||||||
(None, False, True): ('', ''),
|
has_explicit_hash]
|
||||||
(None, True, False): ('none', ''),
|
|
||||||
(None, True, True): ('fn', 'fn-x'),
|
|
||||||
(False, False, False): ('', ''),
|
|
||||||
(False, False, True): ('', ''),
|
|
||||||
(False, True, False): ('', ''),
|
|
||||||
(False, True, True): ('', ''),
|
|
||||||
(True, False, False): ('fn', 'fn-x'),
|
|
||||||
(True, False, True): ('fn', 'fn-x'),
|
|
||||||
(True, True, False): ('fn', 'fn-x'),
|
|
||||||
(True, True, True): ('fn', 'fn-x'),
|
|
||||||
}[None if hash is None else bool(hash), # Force bool() if not None.
|
|
||||||
bool(eq),
|
|
||||||
bool(frozen)]['__hash__' in cls.__dict__]
|
|
||||||
# No need to call _set_new_attribute here, since we already know if
|
# No need to call _set_new_attribute here, since we already know if
|
||||||
# we're overwriting a __hash__ or not.
|
# we're overwriting a __hash__ or not.
|
||||||
if generate_hash == '':
|
if hash_action == '':
|
||||||
# Do nothing.
|
# Do nothing.
|
||||||
pass
|
pass
|
||||||
elif generate_hash == 'none':
|
elif hash_action == 'none':
|
||||||
cls.__hash__ = None
|
cls.__hash__ = None
|
||||||
elif generate_hash in ('fn', 'fn-x'):
|
elif hash_action == 'add':
|
||||||
if generate_hash == 'fn' or auto_hash_test:
|
flds = [f for f in field_list if (f.compare if f.hash is None else f.hash)]
|
||||||
flds = [f for f in field_list
|
cls.__hash__ = _hash_fn(flds)
|
||||||
if (f.compare if f.hash is None else f.hash)]
|
elif hash_action == 'exception':
|
||||||
cls.__hash__ = _hash_fn(flds)
|
# Raise an exception.
|
||||||
|
raise TypeError(f'Cannot overwrite attribute __hash__ '
|
||||||
|
f'in class {cls.__name__}')
|
||||||
else:
|
else:
|
||||||
assert False, f"can't get here: {generate_hash}"
|
assert False, f"can't get here: {hash_action}"
|
||||||
|
|
||||||
if not getattr(cls, '__doc__'):
|
if not getattr(cls, '__doc__'):
|
||||||
# Create a class doc-string.
|
# Create a class doc-string.
|
||||||
|
@ -716,7 +738,7 @@ def _process_class(cls, repr, eq, order, hash, init, frozen):
|
||||||
# underscore. The presence of _cls is used to detect if this
|
# underscore. The presence of _cls is used to detect if this
|
||||||
# decorator is being called with parameters or not.
|
# decorator is being called with parameters or not.
|
||||||
def dataclass(_cls=None, *, init=True, repr=True, eq=True, order=False,
|
def dataclass(_cls=None, *, init=True, repr=True, eq=True, order=False,
|
||||||
hash=None, frozen=False):
|
unsafe_hash=None, frozen=False):
|
||||||
"""Returns the same class as was passed in, with dunder methods
|
"""Returns the same class as was passed in, with dunder methods
|
||||||
added based on the fields defined in the class.
|
added based on the fields defined in the class.
|
||||||
|
|
||||||
|
@ -724,13 +746,13 @@ def dataclass(_cls=None, *, init=True, repr=True, eq=True, order=False,
|
||||||
|
|
||||||
If init is true, an __init__() method is added to the class. If
|
If init is true, an __init__() method is added to the class. If
|
||||||
repr is true, a __repr__() method is added. If order is true, rich
|
repr is true, a __repr__() method is added. If order is true, rich
|
||||||
comparison dunder methods are added. If hash is true, a __hash__()
|
comparison dunder methods are added. If unsafe_hash is true, a
|
||||||
method function is added. If frozen is true, fields may not be
|
__hash__() method function is added. If frozen is true, fields may
|
||||||
assigned to after instance creation.
|
not be assigned to after instance creation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def wrap(cls):
|
def wrap(cls):
|
||||||
return _process_class(cls, repr, eq, order, hash, init, frozen)
|
return _process_class(cls, repr, eq, order, unsafe_hash, init, frozen)
|
||||||
|
|
||||||
# See if we're being called as @dataclass or @dataclass().
|
# See if we're being called as @dataclass or @dataclass().
|
||||||
if _cls is None:
|
if _cls is None:
|
||||||
|
@ -793,6 +815,7 @@ def asdict(obj, *, dict_factory=dict):
|
||||||
raise TypeError("asdict() should be called on dataclass instances")
|
raise TypeError("asdict() should be called on dataclass instances")
|
||||||
return _asdict_inner(obj, dict_factory)
|
return _asdict_inner(obj, dict_factory)
|
||||||
|
|
||||||
|
|
||||||
def _asdict_inner(obj, dict_factory):
|
def _asdict_inner(obj, dict_factory):
|
||||||
if _is_dataclass_instance(obj):
|
if _is_dataclass_instance(obj):
|
||||||
result = []
|
result = []
|
||||||
|
@ -832,6 +855,7 @@ def astuple(obj, *, tuple_factory=tuple):
|
||||||
raise TypeError("astuple() should be called on dataclass instances")
|
raise TypeError("astuple() should be called on dataclass instances")
|
||||||
return _astuple_inner(obj, tuple_factory)
|
return _astuple_inner(obj, tuple_factory)
|
||||||
|
|
||||||
|
|
||||||
def _astuple_inner(obj, tuple_factory):
|
def _astuple_inner(obj, tuple_factory):
|
||||||
if _is_dataclass_instance(obj):
|
if _is_dataclass_instance(obj):
|
||||||
result = []
|
result = []
|
||||||
|
@ -849,7 +873,8 @@ def _astuple_inner(obj, tuple_factory):
|
||||||
|
|
||||||
|
|
||||||
def make_dataclass(cls_name, fields, *, bases=(), namespace=None, init=True,
|
def make_dataclass(cls_name, fields, *, bases=(), namespace=None, init=True,
|
||||||
repr=True, eq=True, order=False, hash=None, frozen=False):
|
repr=True, eq=True, order=False, unsafe_hash=None,
|
||||||
|
frozen=False):
|
||||||
"""Return a new dynamically created dataclass.
|
"""Return a new dynamically created dataclass.
|
||||||
|
|
||||||
The dataclass name will be 'cls_name'. 'fields' is an iterable
|
The dataclass name will be 'cls_name'. 'fields' is an iterable
|
||||||
|
@ -869,7 +894,7 @@ def make_dataclass(cls_name, fields, *, bases=(), namespace=None, init=True,
|
||||||
|
|
||||||
For the bases and namespace parameters, see the builtin type() function.
|
For the bases and namespace parameters, see the builtin type() function.
|
||||||
|
|
||||||
The parameters init, repr, eq, order, hash, and frozen are passed to
|
The parameters init, repr, eq, order, unsafe_hash, and frozen are passed to
|
||||||
dataclass().
|
dataclass().
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -894,7 +919,8 @@ def make_dataclass(cls_name, fields, *, bases=(), namespace=None, init=True,
|
||||||
namespace['__annotations__'] = anns
|
namespace['__annotations__'] = anns
|
||||||
cls = type(cls_name, bases, namespace)
|
cls = type(cls_name, bases, namespace)
|
||||||
return dataclass(cls, init=init, repr=repr, eq=eq, order=order,
|
return dataclass(cls, init=init, repr=repr, eq=eq, order=order,
|
||||||
hash=hash, frozen=frozen)
|
unsafe_hash=unsafe_hash, frozen=frozen)
|
||||||
|
|
||||||
|
|
||||||
def replace(obj, **changes):
|
def replace(obj, **changes):
|
||||||
"""Return a new object replacing specified fields with new values.
|
"""Return a new object replacing specified fields with new values.
|
||||||
|
|
|
@ -83,32 +83,59 @@ class TestCase(unittest.TestCase):
|
||||||
class C(B):
|
class C(B):
|
||||||
x: int = 0
|
x: int = 0
|
||||||
|
|
||||||
def test_overwriting_hash(self):
|
def test_overwrite_hash(self):
|
||||||
|
# Test that declaring this class isn't an error. It should
|
||||||
|
# use the user-provided __hash__.
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class C:
|
class C:
|
||||||
x: int
|
x: int
|
||||||
def __hash__(self):
|
def __hash__(self):
|
||||||
pass
|
return 301
|
||||||
|
self.assertEqual(hash(C(100)), 301)
|
||||||
@dataclass(frozen=True,hash=False)
|
|
||||||
class C:
|
|
||||||
x: int
|
|
||||||
def __hash__(self):
|
|
||||||
return 600
|
|
||||||
self.assertEqual(hash(C(0)), 600)
|
|
||||||
|
|
||||||
|
# Test that declaring this class isn't an error. It should
|
||||||
|
# use the generated __hash__.
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class C:
|
class C:
|
||||||
x: int
|
x: int
|
||||||
def __hash__(self):
|
def __eq__(self, other):
|
||||||
pass
|
return False
|
||||||
|
self.assertEqual(hash(C(100)), hash((100,)))
|
||||||
|
|
||||||
@dataclass(frozen=True, hash=False)
|
# But this one should generate an exception, because with
|
||||||
|
# unsafe_hash=True, it's an error to have a __hash__ defined.
|
||||||
|
with self.assertRaisesRegex(TypeError,
|
||||||
|
'Cannot overwrite attribute __hash__'):
|
||||||
|
@dataclass(unsafe_hash=True)
|
||||||
|
class C:
|
||||||
|
def __hash__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Creating this class should not generate an exception,
|
||||||
|
# because even though __hash__ exists before @dataclass is
|
||||||
|
# called, (due to __eq__ being defined), since it's None
|
||||||
|
# that's okay.
|
||||||
|
@dataclass(unsafe_hash=True)
|
||||||
class C:
|
class C:
|
||||||
x: int
|
x: int
|
||||||
def __hash__(self):
|
def __eq__(self):
|
||||||
return 600
|
pass
|
||||||
self.assertEqual(hash(C(0)), 600)
|
# The generated hash function works as we'd expect.
|
||||||
|
self.assertEqual(hash(C(10)), hash((10,)))
|
||||||
|
|
||||||
|
# Creating this class should generate an exception, because
|
||||||
|
# __hash__ exists and is not None, which it would be if it had
|
||||||
|
# been auto-generated do due __eq__ being defined.
|
||||||
|
with self.assertRaisesRegex(TypeError,
|
||||||
|
'Cannot overwrite attribute __hash__'):
|
||||||
|
@dataclass(unsafe_hash=True)
|
||||||
|
class C:
|
||||||
|
x: int
|
||||||
|
def __eq__(self):
|
||||||
|
pass
|
||||||
|
def __hash__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def test_overwrite_fields_in_derived_class(self):
|
def test_overwrite_fields_in_derived_class(self):
|
||||||
# Note that x from C1 replaces x in Base, but the order remains
|
# Note that x from C1 replaces x in Base, but the order remains
|
||||||
|
@ -294,19 +321,6 @@ class TestCase(unittest.TestCase):
|
||||||
"not supported between instances of 'B' and 'C'"):
|
"not supported between instances of 'B' and 'C'"):
|
||||||
fn(B(0), C(0))
|
fn(B(0), C(0))
|
||||||
|
|
||||||
def test_0_field_hash(self):
|
|
||||||
@dataclass(hash=True)
|
|
||||||
class C:
|
|
||||||
pass
|
|
||||||
self.assertEqual(hash(C()), hash(()))
|
|
||||||
|
|
||||||
def test_1_field_hash(self):
|
|
||||||
@dataclass(hash=True)
|
|
||||||
class C:
|
|
||||||
x: int
|
|
||||||
self.assertEqual(hash(C(4)), hash((4,)))
|
|
||||||
self.assertEqual(hash(C(42)), hash((42,)))
|
|
||||||
|
|
||||||
def test_eq_order(self):
|
def test_eq_order(self):
|
||||||
# Test combining eq and order.
|
# Test combining eq and order.
|
||||||
for (eq, order, result ) in [
|
for (eq, order, result ) in [
|
||||||
|
@ -407,25 +421,25 @@ class TestCase(unittest.TestCase):
|
||||||
# Test all 6 cases of:
|
# Test all 6 cases of:
|
||||||
# hash=True/False/None
|
# hash=True/False/None
|
||||||
# compare=True/False
|
# compare=True/False
|
||||||
for (hash_val, compare, result ) in [
|
for (hash_, compare, result ) in [
|
||||||
(True, False, 'field' ),
|
(True, False, 'field' ),
|
||||||
(True, True, 'field' ),
|
(True, True, 'field' ),
|
||||||
(False, False, 'absent'),
|
(False, False, 'absent'),
|
||||||
(False, True, 'absent'),
|
(False, True, 'absent'),
|
||||||
(None, False, 'absent'),
|
(None, False, 'absent'),
|
||||||
(None, True, 'field' ),
|
(None, True, 'field' ),
|
||||||
]:
|
]:
|
||||||
with self.subTest(hash_val=hash_val, compare=compare):
|
with self.subTest(hash=hash_, compare=compare):
|
||||||
@dataclass(hash=True)
|
@dataclass(unsafe_hash=True)
|
||||||
class C:
|
class C:
|
||||||
x: int = field(compare=compare, hash=hash_val, default=5)
|
x: int = field(compare=compare, hash=hash_, default=5)
|
||||||
|
|
||||||
if result == 'field':
|
if result == 'field':
|
||||||
# __hash__ contains the field.
|
# __hash__ contains the field.
|
||||||
self.assertEqual(C(5).__hash__(), hash((5,)))
|
self.assertEqual(hash(C(5)), hash((5,)))
|
||||||
elif result == 'absent':
|
elif result == 'absent':
|
||||||
# The field is not present in the hash.
|
# The field is not present in the hash.
|
||||||
self.assertEqual(C(5).__hash__(), hash(()))
|
self.assertEqual(hash(C(5)), hash(()))
|
||||||
else:
|
else:
|
||||||
assert False, f'unknown result {result!r}'
|
assert False, f'unknown result {result!r}'
|
||||||
|
|
||||||
|
@ -737,7 +751,7 @@ class TestCase(unittest.TestCase):
|
||||||
validate_class(C)
|
validate_class(C)
|
||||||
|
|
||||||
# Now repeat with __hash__.
|
# Now repeat with __hash__.
|
||||||
@dataclass(frozen=True, hash=True)
|
@dataclass(frozen=True, unsafe_hash=True)
|
||||||
class C:
|
class C:
|
||||||
i: int
|
i: int
|
||||||
j: str
|
j: str
|
||||||
|
@ -1107,7 +1121,7 @@ class TestCase(unittest.TestCase):
|
||||||
self.assertEqual(C().x, [])
|
self.assertEqual(C().x, [])
|
||||||
|
|
||||||
# hash
|
# hash
|
||||||
@dataclass(hash=True)
|
@dataclass(unsafe_hash=True)
|
||||||
class C:
|
class C:
|
||||||
x: list = field(default_factory=list, hash=False)
|
x: list = field(default_factory=list, hash=False)
|
||||||
self.assertEqual(astuple(C()), ([],))
|
self.assertEqual(astuple(C()), ([],))
|
||||||
|
@ -2242,28 +2256,13 @@ class TestOrdering(unittest.TestCase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class TestHash(unittest.TestCase):
|
class TestHash(unittest.TestCase):
|
||||||
def test_hash(self):
|
def test_unsafe_hash(self):
|
||||||
@dataclass(hash=True)
|
@dataclass(unsafe_hash=True)
|
||||||
class C:
|
class C:
|
||||||
x: int
|
x: int
|
||||||
y: str
|
y: str
|
||||||
self.assertEqual(hash(C(1, 'foo')), hash((1, 'foo')))
|
self.assertEqual(hash(C(1, 'foo')), hash((1, 'foo')))
|
||||||
|
|
||||||
def test_hash_false(self):
|
|
||||||
@dataclass(hash=False)
|
|
||||||
class C:
|
|
||||||
x: int
|
|
||||||
y: str
|
|
||||||
self.assertNotEqual(hash(C(1, 'foo')), hash((1, 'foo')))
|
|
||||||
|
|
||||||
def test_hash_none(self):
|
|
||||||
@dataclass(hash=None)
|
|
||||||
class C:
|
|
||||||
x: int
|
|
||||||
with self.assertRaisesRegex(TypeError,
|
|
||||||
"unhashable type: 'C'"):
|
|
||||||
hash(C(1))
|
|
||||||
|
|
||||||
def test_hash_rules(self):
|
def test_hash_rules(self):
|
||||||
def non_bool(value):
|
def non_bool(value):
|
||||||
# Map to something else that's True, but not a bool.
|
# Map to something else that's True, but not a bool.
|
||||||
|
@ -2273,89 +2272,73 @@ class TestHash(unittest.TestCase):
|
||||||
return (3,)
|
return (3,)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def test(case, hash, eq, frozen, with_hash, result):
|
def test(case, unsafe_hash, eq, frozen, with_hash, result):
|
||||||
with self.subTest(case=case, hash=hash, eq=eq, frozen=frozen):
|
with self.subTest(case=case, unsafe_hash=unsafe_hash, eq=eq,
|
||||||
if with_hash:
|
frozen=frozen):
|
||||||
@dataclass(hash=hash, eq=eq, frozen=frozen)
|
if result != 'exception':
|
||||||
class C:
|
if with_hash:
|
||||||
def __hash__(self):
|
@dataclass(unsafe_hash=unsafe_hash, eq=eq, frozen=frozen)
|
||||||
return 0
|
class C:
|
||||||
else:
|
def __hash__(self):
|
||||||
@dataclass(hash=hash, eq=eq, frozen=frozen)
|
return 0
|
||||||
class C:
|
else:
|
||||||
pass
|
@dataclass(unsafe_hash=unsafe_hash, eq=eq, frozen=frozen)
|
||||||
|
class C:
|
||||||
|
pass
|
||||||
|
|
||||||
# See if the result matches what's expected.
|
# See if the result matches what's expected.
|
||||||
if result in ('fn', 'fn-x'):
|
if result == 'fn':
|
||||||
# __hash__ contains the function we generated.
|
# __hash__ contains the function we generated.
|
||||||
self.assertIn('__hash__', C.__dict__)
|
self.assertIn('__hash__', C.__dict__)
|
||||||
self.assertIsNotNone(C.__dict__['__hash__'])
|
self.assertIsNotNone(C.__dict__['__hash__'])
|
||||||
|
|
||||||
if result == 'fn-x':
|
|
||||||
# This is the "auto-hash test" case. We
|
|
||||||
# should overwrite __hash__ iff there's an
|
|
||||||
# __eq__ and if __hash__=None.
|
|
||||||
|
|
||||||
# There are two ways of getting __hash__=None:
|
|
||||||
# explicitely, and by defining __eq__. If
|
|
||||||
# __eq__ is defined, python will add __hash__
|
|
||||||
# when the class is created.
|
|
||||||
@dataclass(hash=hash, eq=eq, frozen=frozen)
|
|
||||||
class C:
|
|
||||||
def __eq__(self, other): pass
|
|
||||||
__hash__ = None
|
|
||||||
|
|
||||||
# Hash should be overwritten (non-None).
|
|
||||||
self.assertIsNotNone(C.__dict__['__hash__'])
|
|
||||||
|
|
||||||
# Same test as above, but we don't provide
|
|
||||||
# __hash__, it will implicitely set to None.
|
|
||||||
@dataclass(hash=hash, eq=eq, frozen=frozen)
|
|
||||||
class C:
|
|
||||||
def __eq__(self, other): pass
|
|
||||||
|
|
||||||
# Hash should be overwritten (non-None).
|
|
||||||
self.assertIsNotNone(C.__dict__['__hash__'])
|
|
||||||
|
|
||||||
elif result == '':
|
elif result == '':
|
||||||
# __hash__ is not present in our class.
|
# __hash__ is not present in our class.
|
||||||
if not with_hash:
|
if not with_hash:
|
||||||
self.assertNotIn('__hash__', C.__dict__)
|
self.assertNotIn('__hash__', C.__dict__)
|
||||||
|
|
||||||
elif result == 'none':
|
elif result == 'none':
|
||||||
# __hash__ is set to None.
|
# __hash__ is set to None.
|
||||||
self.assertIn('__hash__', C.__dict__)
|
self.assertIn('__hash__', C.__dict__)
|
||||||
self.assertIsNone(C.__dict__['__hash__'])
|
self.assertIsNone(C.__dict__['__hash__'])
|
||||||
|
|
||||||
|
elif result == 'exception':
|
||||||
|
# Creating the class should cause an exception.
|
||||||
|
# This only happens with with_hash==True.
|
||||||
|
assert(with_hash)
|
||||||
|
with self.assertRaisesRegex(TypeError, 'Cannot overwrite attribute __hash__'):
|
||||||
|
@dataclass(unsafe_hash=unsafe_hash, eq=eq, frozen=frozen)
|
||||||
|
class C:
|
||||||
|
def __hash__(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
else:
|
else:
|
||||||
assert False, f'unknown result {result!r}'
|
assert False, f'unknown result {result!r}'
|
||||||
|
|
||||||
# There are 12 cases of:
|
# There are 8 cases of:
|
||||||
# hash=True/False/None
|
# unsafe_hash=True/False
|
||||||
# eq=True/False
|
# eq=True/False
|
||||||
# frozen=True/False
|
# frozen=True/False
|
||||||
# And for each of these, a different result if
|
# And for each of these, a different result if
|
||||||
# __hash__ is defined or not.
|
# __hash__ is defined or not.
|
||||||
for case, (hash, eq, frozen, result_no, result_yes) in enumerate([
|
for case, (unsafe_hash, eq, frozen, res_no_defined_hash, res_defined_hash) in enumerate([
|
||||||
(None, False, False, '', ''),
|
(False, False, False, '', ''),
|
||||||
(None, False, True, '', ''),
|
(False, False, True, '', ''),
|
||||||
(None, True, False, 'none', ''),
|
(False, True, False, 'none', ''),
|
||||||
(None, True, True, 'fn', 'fn-x'),
|
(False, True, True, 'fn', ''),
|
||||||
(False, False, False, '', ''),
|
(True, False, False, 'fn', 'exception'),
|
||||||
(False, False, True, '', ''),
|
(True, False, True, 'fn', 'exception'),
|
||||||
(False, True, False, '', ''),
|
(True, True, False, 'fn', 'exception'),
|
||||||
(False, True, True, '', ''),
|
(True, True, True, 'fn', 'exception'),
|
||||||
(True, False, False, 'fn', 'fn-x'),
|
], 1):
|
||||||
(True, False, True, 'fn', 'fn-x'),
|
test(case, unsafe_hash, eq, frozen, False, res_no_defined_hash)
|
||||||
(True, True, False, 'fn', 'fn-x'),
|
test(case, unsafe_hash, eq, frozen, True, res_defined_hash)
|
||||||
(True, True, True, 'fn', 'fn-x'),
|
|
||||||
], 1):
|
|
||||||
test(case, hash, eq, frozen, False, result_no)
|
|
||||||
test(case, hash, eq, frozen, True, result_yes)
|
|
||||||
|
|
||||||
# Test non-bool truth values, too. This is just to
|
# Test non-bool truth values, too. This is just to
|
||||||
# make sure the data-driven table in the decorator
|
# make sure the data-driven table in the decorator
|
||||||
# handles non-bool values.
|
# handles non-bool values.
|
||||||
test(case, non_bool(hash), non_bool(eq), non_bool(frozen), False, result_no)
|
test(case, non_bool(unsafe_hash), non_bool(eq), non_bool(frozen), False, res_no_defined_hash)
|
||||||
test(case, non_bool(hash), non_bool(eq), non_bool(frozen), True, result_yes)
|
test(case, non_bool(unsafe_hash), non_bool(eq), non_bool(frozen), True, res_defined_hash)
|
||||||
|
|
||||||
|
|
||||||
def test_eq_only(self):
|
def test_eq_only(self):
|
||||||
|
@ -2373,8 +2356,8 @@ class TestHash(unittest.TestCase):
|
||||||
self.assertNotEqual(C(1), C(4))
|
self.assertNotEqual(C(1), C(4))
|
||||||
|
|
||||||
# And make sure things work in this case if we specify
|
# And make sure things work in this case if we specify
|
||||||
# hash=True.
|
# unsafe_hash=True.
|
||||||
@dataclass(hash=True)
|
@dataclass(unsafe_hash=True)
|
||||||
class C:
|
class C:
|
||||||
i: int
|
i: int
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
|
@ -2384,7 +2367,7 @@ class TestHash(unittest.TestCase):
|
||||||
|
|
||||||
# And check that the classes __eq__ is being used, despite
|
# And check that the classes __eq__ is being used, despite
|
||||||
# specifying eq=True.
|
# specifying eq=True.
|
||||||
@dataclass(hash=True, eq=True)
|
@dataclass(unsafe_hash=True, eq=True)
|
||||||
class C:
|
class C:
|
||||||
i: int
|
i: int
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
|
@ -2393,10 +2376,35 @@ class TestHash(unittest.TestCase):
|
||||||
self.assertNotEqual(C(1), C(1))
|
self.assertNotEqual(C(1), C(1))
|
||||||
self.assertEqual(hash(C(1)), hash(C(1.0)))
|
self.assertEqual(hash(C(1)), hash(C(1.0)))
|
||||||
|
|
||||||
|
def test_0_field_hash(self):
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class C:
|
||||||
|
pass
|
||||||
|
self.assertEqual(hash(C()), hash(()))
|
||||||
|
|
||||||
|
@dataclass(unsafe_hash=True)
|
||||||
|
class C:
|
||||||
|
pass
|
||||||
|
self.assertEqual(hash(C()), hash(()))
|
||||||
|
|
||||||
|
def test_1_field_hash(self):
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class C:
|
||||||
|
x: int
|
||||||
|
self.assertEqual(hash(C(4)), hash((4,)))
|
||||||
|
self.assertEqual(hash(C(42)), hash((42,)))
|
||||||
|
|
||||||
|
@dataclass(unsafe_hash=True)
|
||||||
|
class C:
|
||||||
|
x: int
|
||||||
|
self.assertEqual(hash(C(4)), hash((4,)))
|
||||||
|
self.assertEqual(hash(C(42)), hash((42,)))
|
||||||
|
|
||||||
def test_hash_no_args(self):
|
def test_hash_no_args(self):
|
||||||
# Test dataclasses with no hash= argument. This exists to
|
# Test dataclasses with no hash= argument. This exists to
|
||||||
# make sure that when hash is changed, the default hashability
|
# make sure that if the @dataclass parameter name is changed
|
||||||
# keeps working.
|
# or the non-default hashing behavior changes, the default
|
||||||
|
# hashability keeps working the same way.
|
||||||
|
|
||||||
class Base:
|
class Base:
|
||||||
def __hash__(self):
|
def __hash__(self):
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
Remove the tri-state parameter "hash", and add the boolean "unsafe_hash". If
|
||||||
|
unsafe_hash is True, add a __hash__ function, but if a __hash__ exists,
|
||||||
|
raise TypeError. If unsafe_hash is False, add a __hash__ based on the
|
||||||
|
values of eq= and frozen=. The unsafe_hash=False behavior is the same as
|
||||||
|
the old hash=None behavior. unsafe_hash=False is the default, just as
|
||||||
|
hash=None used to be.
|
Loading…
Reference in New Issue