Repository: python-attrs/attrs

Problem statement:
Allow using attrs without ctypes
#226 introduced the use of `ctypes`. I use attrs in a Google App Engine application. The app runs in a sandbox which limits the use of certain modules. `ctypes` is one of those libraries.

I can peg my app to use only attrs 17.2.0, but it would be nice to have a fail-gently approach where, if ctypes is unavailable, attrs keeps working, though the behavior allowed by #226 of course would not work.


Hints:
Now, that’s unfortunate!  Making the support unconditional is gonna be easy, the big question is how to document/communicate the behavior it to the user. 🤔
Probably log a warning? Hopefully in a way that's visible.
Also, we won't need ctypes in CPython 3.7 right?

Patch:
diff --git a/src/attr/_compat.py b/src/attr/_compat.py
--- a/src/attr/_compat.py
+++ b/src/attr/_compat.py
@@ -3,6 +3,7 @@
 import platform
 import sys
 import types
+import warnings
 
 
 PY2 = sys.version_info[0] == 2
@@ -85,11 +86,54 @@ def iteritems(d):
     def metadata_proxy(d):
         return types.MappingProxyType(dict(d))
 
-if PYPY:  # pragma: no cover
-    def set_closure_cell(cell, value):
-        cell.__setstate__((value,))
+
+def import_ctypes():  # pragma: nocover
+    """
+    Moved into a function for testability.
+    """
+    try:
+        import ctypes
+        return ctypes
+    except ImportError:
+        return None
+
+
+if not PY2:
+    def just_warn(*args, **kw):
+        """
+        We only warn on Python 3 because we are not aware of any concrete
+        consequences of not setting the cell on Python 2.
+        """
+        warnings.warn(
+            "Missing ctypes.  Some features like bare super() or accessing "
+            "__class__ will not work with slots classes.",
+            RuntimeWarning,
+            stacklevel=2,
+        )
 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
+    def just_warn(*args, **kw):  # pragma: nocover
+        """
+        We only warn on Python 3 because we are not aware of any concrete
+        consequences of not setting the cell on Python 2.
+        """
+
+
+def make_set_closure_cell():
+    """
+    Moved into a function for testability.
+    """
+    if PYPY:  # pragma: no cover
+        def set_closure_cell(cell, value):
+            cell.__setstate__((value,))
+    else:
+        ctypes = import_ctypes()
+        if ctypes is not None:
+            set_closure_cell = ctypes.pythonapi.PyCell_Set
+            set_closure_cell.argtypes = (ctypes.py_object, ctypes.py_object)
+            set_closure_cell.restype = ctypes.c_int
+        else:
+            set_closure_cell = just_warn
+    return set_closure_cell
+
+
+set_closure_cell = make_set_closure_cell()


Test patch:
diff --git a/tests/test_slots.py b/tests/test_slots.py
--- a/tests/test_slots.py
+++ b/tests/test_slots.py
@@ -13,7 +13,7 @@
 
 import attr
 
-from attr._compat import PY2
+from attr._compat import PY2, PYPY, just_warn, make_set_closure_cell
 
 
 @attr.s
@@ -325,76 +325,98 @@ class C2(C1Bare):
 
 
 @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
-
-
-@pytest.mark.skipif(PY2, reason="closure cell rewriting is PY3-only.")
-@pytest.mark.parametrize("slots", [True, False])
-def test_closure_cell_rewriting_cls_static(slots):
-    """
-    Slot classes support proper closure cell rewriting for class- and static
-    methods.
-    """
-    # Python can reuse closure cells, so we create new classes just for
-    # this test.
-
-    @attr.s(slots=slots)
-    class C:
-        @classmethod
-        def clsmethod(cls):
-            return __class__  # noqa: F821
-
-    assert C.clsmethod() is C
-
-    @attr.s(slots=slots)
-    class D:
-        @staticmethod
-        def statmethod():
-            return __class__  # noqa: F821
-
-    assert D.statmethod() is D
+class TestClosureCellRewriting(object):
+    def test_closure_cell_rewriting(self):
+        """
+        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()
+
+    def test_inheritance(self):
+        """
+        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
+
+    @pytest.mark.parametrize("slots", [True, False])
+    def test_cls_static(self, slots):
+        """
+        Slot classes support proper closure cell rewriting for class- and
+        static methods.
+        """
+        # Python can reuse closure cells, so we create new classes just for
+        # this test.
+
+        @attr.s(slots=slots)
+        class C:
+            @classmethod
+            def clsmethod(cls):
+                return __class__  # noqa: F821
+
+        assert C.clsmethod() is C
+
+        @attr.s(slots=slots)
+        class D:
+            @staticmethod
+            def statmethod():
+                return __class__  # noqa: F821
+
+        assert D.statmethod() is D
+
+    @pytest.mark.skipif(
+        PYPY,
+        reason="ctypes are used only on CPython"
+    )
+    def test_missing_ctypes(self, monkeypatch):
+        """
+        Keeps working if ctypes is missing.
+
+        A warning is emitted that points to the actual code.
+        """
+        monkeypatch.setattr(attr._compat, "import_ctypes", lambda: None)
+        func = make_set_closure_cell()
+
+        with pytest.warns(RuntimeWarning) as wr:
+            func()
+
+        w = wr.pop()
+        assert __file__ == w.filename
+        assert (
+            "Missing ctypes.  Some features like bare super() or accessing "
+            "__class__ will not work with slots classes.",
+        ) == w.message.args
+
+        assert just_warn is func