Repository: python-attrs/attrs
Pull request #1079 (2023-01-03 01:47:56)

First pass over generics
Support generic classes for `has` and `fields`.

Tinche: @hynek *chants* dump 3.6, dump 3.6
hynek: _chants_ coverage, coverage

hynek: Do you plan on finishing this up or can I close it?
Tinche: This can be merged, it can just be worked on further, but the further work gets a lot more complex. This first step has value on its own.
hynek: This cannot be merged because it's [breaking CI](https://github.com/python-attrs/attrs/pull/1079#issuecomment-1379043851).
Tinche: Ah, oops. Will work on it some more.
Tinche: Ok, there's something funky with coverage.

Locally, I'm getting this:

![Screenshot 2023-02-03 at 16 36 25](https://user-images.githubusercontent.com/1909233/216643956-55ed891b-487d-49ac-bea5-55eab206e8e6.png)

But this is obviously wrong, since if I put an Exception below the `if`, one test blows up. So the lines are being hit for sure.
Any ideas?
hynek: Does it maybe only get hit in a version that doesn't run under coverage?
Tinche: This was me running coverage _locally_, on Python 3.11. Going to try working on this some more.
Tinche: Ok, I got it.

Running coverage with this configuration is a little trickier than I thought. I had to run:

`❯ rm .coverage*; coverage run -m pytest; and coverage combine;and coverage html;and open htmlcov/index.html`

But then I get accurage coverage reports (modulo other Python versions, which are not important here).

Anyway, CI is green now.

Patch:
diff --git a/src/attr/_compat.py b/src/attr/_compat.py
index 35a85a3..cc6327e 100644
--- a/src/attr/_compat.py
+++ b/src/attr/_compat.py
@@ -9,6 +9,7 @@ import types
 import warnings
 
 from collections.abc import Mapping, Sequence  # noqa
+from typing import _GenericAlias
 
 
 PYPY = platform.python_implementation() == "PyPy"
@@ -174,3 +175,10 @@ set_closure_cell = make_set_closure_cell()
 # don't have a direct reference to the thread-local in their globals dict.
 # If they have such a reference, it breaks cloudpickle.
 repr_context = threading.local()
+
+
+def get_generic_base(cl):
+    """If this is a generic class (A[str]), return the generic base for it."""
+    if cl.__class__ is _GenericAlias:
+        return cl.__origin__
+    return None
diff --git a/src/attr/_funcs.py b/src/attr/_funcs.py
index 518be16..6fa2456 100644
--- a/src/attr/_funcs.py
+++ b/src/attr/_funcs.py
@@ -3,6 +3,7 @@
 
 import copy
 
+from ._compat import get_generic_base
 from ._make import NOTHING, _obj_setattr, fields
 from .exceptions import AttrsAttributeNotFoundError
 
@@ -296,7 +297,19 @@ def has(cls):
 
     :rtype: bool
     """
-    return getattr(cls, "__attrs_attrs__", None) is not None
+    attrs = getattr(cls, "__attrs_attrs__", None)
+    if attrs is not None:
+        return True
+
+    # No attrs, maybe it's a specialized generic (A[str])?
+    generic_base = get_generic_base(cls)
+    if generic_base is not None:
+        generic_attrs = getattr(generic_base, "__attrs_attrs__", None)
+        if generic_attrs is not None:
+            # Stick it on here for speed next time.
+            cls.__attrs_attrs__ = generic_attrs
+        return generic_attrs is not None
+    return False
 
 
 def assoc(inst, **changes):
diff --git a/src/attr/_make.py b/src/attr/_make.py
index 014b7bc..d72f738 100644
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -12,7 +12,12 @@ from operator import itemgetter
 # We need to import _compat itself in addition to the _compat members to avoid
 # having the thread-local in the globals here.
 from . import _compat, _config, setters
-from ._compat import PY310, _AnnotationExtractor, set_closure_cell
+from ._compat import (
+    PY310,
+    _AnnotationExtractor,
+    get_generic_base,
+    set_closure_cell,
+)
 from .exceptions import (
     DefaultAlreadySetError,
     FrozenInstanceError,
@@ -1918,12 +1923,26 @@ def fields(cls):
 
     .. versionchanged:: 16.2.0 Returned tuple allows accessing the fields
        by name.
+    .. versionchanged:: 23.1.0 Add support for generic classes.
     """
-    if not isinstance(cls, type):
+    generic_base = get_generic_base(cls)
+
+    if generic_base is None and not isinstance(cls, type):
         raise TypeError("Passed object must be a class.")
+
     attrs = getattr(cls, "__attrs_attrs__", None)
+
     if attrs is None:
+        if generic_base is not None:
+            attrs = getattr(generic_base, "__attrs_attrs__", None)
+            if attrs is not None:
+                # Even though this is global state, stick it on here to speed
+                # it up. We rely on `cls` being cached for this to be
+                # efficient.
+                cls.__attrs_attrs__ = attrs
+                return attrs
         raise NotAnAttrsClassError(f"{cls!r} is not an attrs-decorated class.")
+
     return attrs
 
 


Test patch:
diff --git a/tests/test_funcs.py b/tests/test_funcs.py
index f77bfd4..9f68456 100644
--- a/tests/test_funcs.py
+++ b/tests/test_funcs.py
@@ -6,6 +6,7 @@ Tests for `attr._funcs`.
 
 
 from collections import OrderedDict
+from typing import Generic, TypeVar
 
 import pytest
 
@@ -418,6 +419,37 @@ class TestHas:
         """
         assert not has(object)
 
+    def test_generics(self):
+        """
+        Works with generic classes.
+        """
+        T = TypeVar("T")
+
+        @attr.define
+        class A(Generic[T]):
+            a: T
+
+        assert has(A)
+
+        assert has(A[str])
+        # Verify twice, since there's caching going on.
+        assert has(A[str])
+
+    def test_generics_negative(self):
+        """
+        Returns `False` on non-decorated generic classes.
+        """
+        T = TypeVar("T")
+
+        class A(Generic[T]):
+            a: T
+
+        assert not has(A)
+
+        assert not has(A[str])
+        # Verify twice, since there's caching going on.
+        assert not has(A[str])
+
 
 class TestAssoc:
     """
diff --git a/tests/test_make.py b/tests/test_make.py
index 79373d3..127de5d 100644
--- a/tests/test_make.py
+++ b/tests/test_make.py
@@ -13,6 +13,7 @@ import itertools
 import sys
 
 from operator import attrgetter
+from typing import Generic, TypeVar
 
 import pytest
 
@@ -1114,6 +1115,22 @@ class TestFields:
             f"{object!r} is not an attrs-decorated class."
         ) == e.value.args[0]
 
+    def test_handler_non_attrs_generic_class(self):
+        """
+        Raises `ValueError` if passed a non-*attrs* generic class.
+        """
+        T = TypeVar("T")
+
+        class B(Generic[T]):
+            pass
+
+        with pytest.raises(NotAnAttrsClassError) as e:
+            fields(B[str])
+
+        assert (
+            f"{B[str]!r} is not an attrs-decorated class."
+        ) == e.value.args[0]
+
     @given(simple_classes())
     def test_fields(self, C):
         """
@@ -1129,6 +1146,24 @@ class TestFields:
         for attribute in fields(C):
             assert getattr(fields(C), attribute.name) is attribute
 
+    def test_generics(self):
+        """
+        Fields work with generic classes.
+        """
+        T = TypeVar("T")
+
+        @attr.define
+        class A(Generic[T]):
+            a: T
+
+        assert len(fields(A)) == 1
+        assert fields(A).a.name == "a"
+        assert fields(A).a.default is attr.NOTHING
+
+        assert len(fields(A[str])) == 1
+        assert fields(A[str]).a.name == "a"
+        assert fields(A[str]).a.default is attr.NOTHING
+
 
 class TestFieldsDict:
     """