From 47583a945908ffc68de72c31b7d948c555eb4dfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Thu, 3 Aug 2017 17:04:48 +0200 Subject: [PATCH] Bugfix/slot super (#226) --- .travis.yml | 2 ++ CHANGELOG.rst | 7 +++-- src/attr/_compat.py | 11 ++++++++ src/attr/_make.py | 24 +++++++++++++++- tests/test_slots.py | 67 +++++++++++++++++++++++++++++++++++++++++++++ tox.ini | 2 +- 6 files changed, 109 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index d3f21dcd4..a96af0f79 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,6 +18,8 @@ matrix: env: TOXENV=py36 - python: "pypy" env: TOXENV=pypy + - python: "pypy3.5-5.8.0" + env: TOXENV=pypy3 # Meta - python: "3.6" diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 05b397721..60c5c8fd6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -24,9 +24,12 @@ Deprecations: Changes: ^^^^^^^^ -- Fix *str* on Python 2 when ``slots=True``. +- ``super()`` and ``__class__`` now work on Python 3 when ``slots=True``. + `#102 `_ + `#226 `_ +- The combination of ``str=True`` and ``slots=True`` now works on Python 2. `#198 `_ -- ``attr.Factory`` is now hashable again. +- ``attr.Factory`` is hashable again. `#204 `_ diff --git a/src/attr/_compat.py b/src/attr/_compat.py index 8cbcf16f4..439d21241 100644 --- a/src/attr/_compat.py +++ b/src/attr/_compat.py @@ -1,10 +1,12 @@ from __future__ import absolute_import, division, print_function +import platform import sys import types PY2 = sys.version_info[0] == 2 +PYPY = platform.python_implementation() == "PyPy" if PY2: @@ -88,3 +90,12 @@ def iterkeys(d): def metadata_proxy(d): return types.MappingProxyType(dict(d)) + +if PYPY: # pragma: no cover + def set_closure_cell(cell, value): + cell.__setstate__((value,)) +else: + import ctypes + set_closure_cell = ctypes.pythonapi.PyCell_Set + set_closure_cell.argtypes = (ctypes.py_object, ctypes.py_object) + set_closure_cell.restype = ctypes.c_int diff --git a/src/attr/_make.py b/src/attr/_make.py index b22a3f8d3..e687dfadb 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -6,7 +6,14 @@ from operator import itemgetter from . import _config -from ._compat import PY2, iteritems, isclass, iterkeys, metadata_proxy +from ._compat import ( + PY2, + iteritems, + isclass, + iterkeys, + metadata_proxy, + set_closure_cell, +) from .exceptions import ( DefaultAlreadySetError, FrozenInstanceError, @@ -373,12 +380,27 @@ def wrap(cls): # It might not actually be in there, e.g. if using 'these'. cls_dict.pop(ca_name, None) cls_dict.pop("__dict__", None) + old_cls = cls qualname = getattr(cls, "__qualname__", None) cls = type(cls)(cls.__name__, cls.__bases__, cls_dict) if qualname is not None: cls.__qualname__ = qualname + # The following is a fix for + # https://github.com/python-attrs/attrs/issues/102. On Python 3, + # if a method mentions `__class__` or uses the no-arg super(), the + # compiler will bake a reference to the class in the method itself + # as `method.__closure__`. Since we replace the class with a + # clone, we rewrite these references so it keeps working. + for item in cls.__dict__.values(): + closure_cells = getattr(item, "__closure__", None) + if not closure_cells: # Catch None or the empty list. + continue + for cell in closure_cells: + if cell.cell_contents is old_cls: + set_closure_cell(cell, cls) + return cls # attrs_or class type depends on the usage of the decorator. It's a class diff --git a/tests/test_slots.py b/tests/test_slots.py index a850575ae..b930b2ef1 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -13,6 +13,8 @@ import attr +from attr._compat import PY2 + @attr.s class C1(object): @@ -30,6 +32,14 @@ def classmethod(cls): def staticmethod(): return "staticmethod" + if not PY2: + def my_class(self): + return __class__ # NOQA: F821 + + def my_super(self): + """Just to test out the no-arg super.""" + return super().__repr__() + @attr.s(slots=True, hash=True) class C1Slots(object): @@ -47,6 +57,14 @@ def classmethod(cls): def staticmethod(): return "staticmethod" + if not PY2: + def my_class(self): + return __class__ # NOQA: F821 + + def my_super(self): + """Just to test out the no-arg super.""" + return super().__repr__() + def test_slots_being_used(): """ @@ -298,3 +316,52 @@ class C2(C1Bare): hash(c2) # Just to assert it doesn't raise. assert {"x": 1, "y": 2, "z": "test"} == attr.asdict(c2) + + +@pytest.mark.skipif(PY2, reason="closure cell rewriting is PY3-only.") +def test_closure_cell_rewriting(): + """ + Slot classes support proper closure cell rewriting. + + This affects features like `__class__` and the no-arg super(). + """ + non_slot_instance = C1(x=1, y="test") + slot_instance = C1Slots(x=1, y="test") + + assert non_slot_instance.my_class() is C1 + assert slot_instance.my_class() is C1Slots + + # Just assert they return something, and not an exception. + assert non_slot_instance.my_super() + assert slot_instance.my_super() + + +@pytest.mark.skipif(PY2, reason="closure cell rewriting is PY3-only.") +def test_closure_cell_rewriting_inheritance(): + """ + Slot classes support proper closure cell rewriting when inheriting. + + This affects features like `__class__` and the no-arg super(). + """ + @attr.s + class C2(C1): + def my_subclass(self): + return __class__ # NOQA: F821 + + @attr.s + class C2Slots(C1Slots): + def my_subclass(self): + return __class__ # NOQA: F821 + + non_slot_instance = C2(x=1, y="test") + slot_instance = C2Slots(x=1, y="test") + + assert non_slot_instance.my_class() is C1 + assert slot_instance.my_class() is C1Slots + + # Just assert they return something, and not an exception. + assert non_slot_instance.my_super() + assert slot_instance.my_super() + + assert non_slot_instance.my_subclass() is C2 + assert slot_instance.my_subclass() is C2Slots diff --git a/tox.ini b/tox.ini index ab2842e99..2554660bd 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27,py34,py35,py36,pypy,flake8,manifest,docs,readme,coverage-report +envlist = py27,py34,py35,py36,pypy,pypy3,flake8,manifest,docs,readme,coverage-report [testenv]