Skip to content

Commit

Permalink
Fix hashing behavior (#142)
Browse files Browse the repository at this point in the history
  • Loading branch information
hynek authored Feb 19, 2017
1 parent 0657718 commit f560500
Show file tree
Hide file tree
Showing 12 changed files with 176 additions and 58 deletions.
11 changes: 10 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ The third digit is only for regressions.
Backward-incompatible changes:
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

*none*
- ``attrs`` will set the ``__hash__`` method to ``None`` by default now.
The way hashes were handled before was in conflict with `Python's specification <https://docs.python.org/3/reference/datamodel.html#object.__hash__>`_.
This *may* break some software although this breakage is most likely just surfacing of latent bugs.
You can always make ``attrs`` create the ``__hash__`` method using ``@attr.s(hash=True)``.
See `#136 <https://github.com/hynek/attrs/issues/136>`_ for the rationale of this change.
- Correspondingly, ``attr.ib``'s ``hash`` argument is ``None`` by default too and mirrors the ``cmp`` argument as it should.


Deprecations:
Expand All @@ -23,6 +28,10 @@ Deprecations:
Changes:
^^^^^^^^

- Fix default hashing behavior.
Now *hash* mirrors the value of *cmp* and classes are unhashable by default.
`#136 <https://github.com/hynek/attrs/issues/136>`_
`#142 <https://github.com/hynek/attrs/issues/142>`_
- Add ``attr.evolve`` that, given an instance of an ``attrs`` class and field changes as keyword arguments, will instantiate a copy of the given instance with the changes applied.
``evolve`` replaces ``assoc``, which is now deprecated.
``evolve`` is significantly faster than ``assoc``, and requires the class have an initializer that can take the field values as keyword arguments (like ``attrs`` itself can generate).
Expand Down
6 changes: 3 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@ attrs: Attributes Without Boilerplate
=====================================

.. image:: https://readthedocs.org/projects/attrs/badge/?version=stable
:target: http://attrs.readthedocs.io/en/stable/?badge=stable
:target: https://attrs.readthedocs.io/en/stable/?badge=stable
:alt: Documentation Status

.. image:: https://travis-ci.org/hynek/attrs.svg?branch=master
:target: https://travis-ci.org/hynek/attrs
:alt: CI status

.. image:: https://codecov.io/github/hynek/attrs/branch/master/graph/badge.svg
:target: https://codecov.io/github/hynek/attrs
:alt: Test Coverage
:target: https://codecov.io/github/hynek/attrs
:alt: Test Coverage

.. teaser-begin
Expand Down
12 changes: 6 additions & 6 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ What follows is the API explanation, if you'd like a more hands-on introduction,
Core
----

.. autofunction:: attr.s(these=None, repr_ns=None, repr=True, cmp=True, hash=True, init=True, slots=False, frozen=False, str=False)
.. autofunction:: attr.s(these=None, repr_ns=None, repr=True, cmp=True, hash=None, init=True, slots=False, frozen=False, str=False)

.. note::

Expand Down Expand Up @@ -71,7 +71,7 @@ Core
... class C(object):
... x = attr.ib()
>>> C.x
Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({}))
Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}))


.. autofunction:: attr.make_class
Expand Down Expand Up @@ -127,9 +127,9 @@ Helpers
... x = attr.ib()
... y = attr.ib()
>>> attr.fields(C)
(Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({})), Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({})))
(Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({})), Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({})))
>>> attr.fields(C)[1]
Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({}))
Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}))
>>> attr.fields(C).y is attr.fields(C)[1]
True

Expand Down Expand Up @@ -217,7 +217,7 @@ See :ref:`asdict` for examples.
>>> attr.validate(i)
Traceback (most recent call last):
...
TypeError: ("'x' must be <type 'int'> (got '1' that is a <type 'str'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, repr=True, cmp=True, hash=True, init=True), <type 'int'>, '1')
TypeError: ("'x' must be <type 'int'> (got '1' that is a <type 'str'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, repr=True, cmp=True, hash=None, init=True), <type 'int'>, '1')


Validators can be globally disabled if you want to run them only in development and tests but not in production because you fear their performance impact:
Expand Down Expand Up @@ -254,7 +254,7 @@ Validators
>>> C(None)
Traceback (most recent call last):
...
TypeError: ("'x' must be <type 'int'> (got None that is a <type 'NoneType'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, repr=True, cmp=True, hash=True, init=True), <type 'int'>, None)
TypeError: ("'x' must be <type 'int'> (got None that is a <type 'NoneType'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, repr=True, cmp=True, hash=None, init=True), <type 'int'>, None)


.. autofunction:: attr.validators.provides
Expand Down
2 changes: 1 addition & 1 deletion docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ Slot classes are a little different than ordinary, dictionary-backed classes:
... class C(object):
... x = attr.ib()
>>> C.x
Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({}))
Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}))
>>> @attr.s(slots=True)
... class C(object):
... x = attr.ib()
Expand Down
4 changes: 2 additions & 2 deletions docs/extending.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ So it is fairly simple to build your own decorators on top of ``attrs``:
... @attr.s
... class C(object):
... a = attr.ib()
(Attribute(name='a', default=NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({})),)
(Attribute(name='a', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({})),)


.. warning::
Expand Down Expand Up @@ -66,7 +66,7 @@ Here are some tips for effective use of metadata:

>>> MY_TYPE_METADATA = '__my_type_metadata'
>>>
>>> def typed(cls, default=attr.NOTHING, validator=None, repr=True, cmp=True, hash=True, init=True, convert=None, metadata={}):
>>> def typed(cls, default=attr.NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata={}):
... metadata = dict() if not metadata else metadata
... metadata[MY_TYPE_METADATA] = cls
... return attr.ib(default, validator, repr, cmp, hash, init, convert, metadata)
Expand Down
2 changes: 1 addition & 1 deletion src/attr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@
"Factory",
"NOTHING",
"asdict",
"astuple",
"assoc",
"astuple",
"attr",
"attrib",
"attributes",
Expand Down
60 changes: 51 additions & 9 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def __hash__(self):


def attr(default=NOTHING, validator=None,
repr=True, cmp=True, hash=True, init=True,
repr=True, cmp=True, hash=None, init=True,
convert=None, metadata={}):
"""
Create a new attribute on a class.
Expand Down Expand Up @@ -92,8 +92,11 @@ def attr(default=NOTHING, validator=None,
method.
:param bool cmp: Include this attribute in the generated comparison methods
(``__eq__`` et al).
:param bool hash: Include this attribute in the generated ``__hash__``
method.
:param hash: Include this attribute in the generated ``__hash__``
method. If ``None`` (default), mirror *cmp*'s value. This is the
correct behavior according the Python spec. Setting this value to
anything else than ``None`` is *discouraged*.
:type hash: ``bool`` or ``None``
:param bool init: Include this attribute in the generated ``__init__``
method. It is possible to set this to ``False`` and set a default
value. In that case this attributed is unconditionally initialized
Expand All @@ -104,10 +107,16 @@ def attr(default=NOTHING, validator=None,
returned value will be used as the new value of the attribute. The
value is converted before being passed to the validator, if any.
:param metadata: An arbitrary mapping, to be used by third-party
components.
components. See :ref:`extending_metadata`.
.. versionchanged:: 17.1.0 *validator* can be a ``list`` now.
.. versionchanged:: 17.1.0
*hash* is ``None`` and therefore mirrors *cmp* by default .
"""
if hash is not None and hash is not True and hash is not False:
raise TypeError(
"Invalid value for hash. Must be True, False, or None."
)
return _CountingAttr(
default=default,
validator=validator,
Expand Down Expand Up @@ -216,7 +225,7 @@ def _frozen_delattrs(self, name):


def attributes(maybe_cls=None, these=None, repr_ns=None,
repr=True, cmp=True, hash=True, init=True,
repr=True, cmp=True, hash=None, init=True,
slots=False, frozen=False, str=False):
r"""
A class decorator that adds `dunder
Expand Down Expand Up @@ -245,8 +254,26 @@ def attributes(maybe_cls=None, these=None, repr_ns=None,
``__gt__``, and ``__ge__`` methods that compare the class as if it were
a tuple of its ``attrs`` attributes. But the attributes are *only*
compared, if the type of both classes is *identical*!
:param bool hash: Create a ``__hash__`` method that returns the
:func:`hash` of a tuple of all ``attrs`` attribute values.
:param hash: If ``None`` (default), the ``__hash__`` method is generated
according how *cmp* and *frozen* are set.
1. If *both* are True, ``attrs`` will generate a ``__hash__`` for you.
2. If *cmp* is True and *frozen* is False, ``__hash__`` will be set to
None, marking it unhashable (which it is).
3. If *cmp* is False, ``__hash__`` will be left untouched meaning the
``__hash__`` method of the superclass will be used (if superclass is
``object``, this means it will fall back to id-based hashing.).
Although not recommended, you can decide for yourself and force
``attrs`` to create one (e.g. if the class is immutable even though you
didn't freeze it programmatically) by passing ``True`` or not. Both of
these cases are rather special and should be used carefully.
See the `Python documentation \
<https://docs.python.org/3/reference/datamodel.html#object.__hash__>`_
and the `GitHub issue that led to the default behavior \
<https://github.com/hynek/attrs/issues/136>`_ for more details.
:type hash: ``bool`` or ``None``
:param bool init: Create a ``__init__`` method that initialiazes the
``attrs`` attributes. Leading underscores are stripped for the
argument name. If a ``__attrs_post_init__`` method exists on the
Expand All @@ -273,6 +300,9 @@ def attributes(maybe_cls=None, these=None, repr_ns=None,
.. versionadded:: 16.0.0 *slots*
.. versionadded:: 16.1.0 *frozen*
.. versionadded:: 16.3.0 *str*, and support for ``__attrs_post_init__``.
.. versionchanged::
17.1.0 *hash* supports ``None`` as value which is also the default
now.
"""
def wrap(cls):
if getattr(cls, "__class__", None) is None:
Expand Down Expand Up @@ -303,8 +333,18 @@ def wrap(cls):
cls.__str__ = cls.__repr__
if cmp is True:
cls = _add_cmp(cls)
if hash is True:

if hash is not True and hash is not False and hash is not None:
raise TypeError(
"Invalid value for hash. Must be True, False, or None."
)
elif hash is False or (hash is None and cmp is False):
pass
elif hash is True or (hash is None and cmp is True and frozen is True):
cls = _add_hash(cls)
else:
cls.__hash__ = None

if init is True:
cls = _add_init(cls, effectively_frozen)
if effectively_frozen is True:
Expand Down Expand Up @@ -369,7 +409,9 @@ def _add_hash(cls, attrs=None):
Add a hash method to *cls*.
"""
if attrs is None:
attrs = [a for a in cls.__attrs_attrs__ if a.hash]
attrs = [a
for a in cls.__attrs_attrs__
if a.hash is True or (a.hash is None and a.cmp is True)]

def hash_(self):
"""
Expand Down
10 changes: 6 additions & 4 deletions tests/test_dark_magic.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from __future__ import absolute_import, division, print_function

import pickle

import pytest

from hypothesis import given
from hypothesis.strategies import booleans

Expand Down Expand Up @@ -91,9 +93,9 @@ def test_fields(self, cls):
"""
assert (
Attribute(name="x", default=foo, _validator=None,
repr=True, cmp=True, hash=True, init=True),
repr=True, cmp=True, hash=None, init=True),
Attribute(name="y", default=attr.Factory(list), _validator=None,
repr=True, cmp=True, hash=True, init=True),
repr=True, cmp=True, hash=None, init=True),
) == attr.fields(cls)

@pytest.mark.parametrize("cls", [C1, C1Slots])
Expand Down Expand Up @@ -140,9 +142,9 @@ def test_programmatic(self, slots, frozen):
PC = attr.make_class("PC", ["a", "b"], slots=slots, frozen=frozen)
assert (
Attribute(name="a", default=NOTHING, _validator=None,
repr=True, cmp=True, hash=True, init=True),
repr=True, cmp=True, hash=None, init=True),
Attribute(name="b", default=NOTHING, _validator=None,
repr=True, cmp=True, hash=True, init=True),
repr=True, cmp=True, hash=None, init=True),
) == attr.fields(PC)

@pytest.mark.parametrize("cls", [Sub, SubSlots])
Expand Down
77 changes: 73 additions & 4 deletions tests/test_dunders.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@
CmpCSlots = simple_class(cmp=True, slots=True)
ReprC = simple_class(repr=True)
ReprCSlots = simple_class(repr=True, slots=True)

# HashC is hashable by explicit definition while HashCSlots is hashable
# implicitly.
HashC = simple_class(hash=True)
HashCSlots = simple_class(hash=True, slots=True)
HashCSlots = simple_class(hash=None, cmp=True, frozen=True, slots=True)


class InitC(object):
Expand Down Expand Up @@ -227,22 +230,88 @@ class TestAddHash(object):
"""
Tests for `_add_hash`.
"""
def test_enforces_type(self):
"""
The `hash` argument to both attrs and attrib must be None, True, or
False.
"""
exc_args = ("Invalid value for hash. Must be True, False, or None.",)

with pytest.raises(TypeError) as e:
make_class("C", {}, hash=1),

assert exc_args == e.value.args

with pytest.raises(TypeError) as e:
make_class("C", {"a": attr(hash=1)}),

assert exc_args == e.value.args

@given(booleans())
def test_hash(self, slots):
def test_hash_attribute(self, slots):
"""
If `hash` is False, ignore that attribute.
If `hash` is False on an attribute, ignore that attribute.
"""
C = make_class("C", {"a": attr(hash=False), "b": attr()}, slots=slots)
C = make_class("C", {"a": attr(hash=False), "b": attr()},
slots=slots, hash=True)

assert hash(C(1, 2)) == hash(C(2, 2))

@given(booleans())
def test_hash_attribute_mirrors_cmp(self, cmp):
"""
If `hash` is None, the hash generation mirrors `cmp`.
"""
C = make_class("C", {"a": attr(cmp=cmp)}, cmp=True, frozen=True)

if cmp:
assert C(1) != C(2)
assert hash(C(1)) != hash(C(2))
assert hash(C(1)) == hash(C(1))
else:
assert C(1) == C(2)
assert hash(C(1)) == hash(C(2))

@given(booleans())
def test_hash_mirrors_cmp(self, cmp):
"""
If `hash` is None, the hash generation mirrors `cmp`.
"""
C = make_class("C", {"a": attr()}, cmp=cmp, frozen=True)

i = C(1)

assert i == i
assert hash(i) == hash(i)

if cmp:
assert C(1) == C(1)
assert hash(C(1)) == hash(C(1))
else:
assert C(1) != C(1)
assert hash(C(1)) != hash(C(1))

@pytest.mark.parametrize("cls", [HashC, HashCSlots])
def test_hash_works(self, cls):
"""
__hash__ returns different hashes for different values.
"""
assert hash(cls(1, 2)) != hash(cls(1, 1))

def test_hash_default(self):
"""
Classes are not hashable by default.
"""
C = make_class("C", {})

with pytest.raises(TypeError) as e:
hash(C())

assert e.value.args[0] in (
"'C' objects are unhashable", # PyPy
"unhashable type: 'C'", # CPython
)


class TestAddInit(object):
"""
Expand Down
Loading

0 comments on commit f560500

Please sign in to comment.