Repository: python/mypy

Issue:
Support for `attr.has` and `attr.AttrsInstance`
**Feature**
Recently, mypy added a plugin for `attr.fields` that tries to detect whether a type is an `attr.s`-decorated class, and reports an error if it isn't. However, `attrs` has a couple ways to check or annotate that a class is an `attr.s`-decorated class: `attr.has` and `attr.AttrsInstance`, neither of which seem to be supported by mypy right now.

`attr.AttrsInstance` is a protocol defined in the attrs internal stubs (https://github.com/python-attrs/attrs/blob/main/src/attr/__init__.pyi#L74) that represents that an object of an `attr.s`-decorated class. Right now, the plugin doesn't recognize `attr.AttrsInstance`, so it will report an error when trying to call e.g. `attr.fields` on a `type[attr.AttrsInstance]`.

`attr.has` is a method that checks whether a class is `attr.s`-decorated. The plugin won't recognize this as a valid way to ensure  `attrs` version 22.2.0 updated the internal stubs for `attr.has` to be a `TypeGuard` for `type[attr.AttrsInstance]`: (https://github.com/python-attrs/attrs/blob/main/src/attr/__init__.pyi#L541), so supporting `attr.AttrsInstance` may be sufficient when using sufficiently new stubs for `attrs`.

**Pitch**

A possible use for `attr.has`:
```
T = TypeVar('T')

def deserialize(attr_cls: type[T], value:bytes) -> T:
    assert attr.has(cls)
    fields = attr.fields(cls)
    ...
```

A possible use for `attr.AttrsInstance`:
```
T = TypeVar('T', bound=attr.AttrsInstance)

def deserialize(attr_cls: type[T], value: bytes) -> T:
    fields = attr.fields(attr_cls)
    ...
```



Discussion:
@hauntsaninja this is the thing that I was having trouble with with the new behavior of the plugin. I think it probably counts as a feature request because we didn't really support these before either, we just wouldn't error on them because we didn't check the types around `attr.fields`.

Resolving patch:
diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py
--- a/mypy/plugins/attrs.py
+++ b/mypy/plugins/attrs.py
@@ -1111,9 +1111,4 @@ def fields_function_sig_callback(ctx: mypy.plugin.FunctionSigContext) -> Callabl
         assert ret_type is not None
         return ctx.default_signature.copy_modified(arg_types=arg_types, ret_type=ret_type)
 
-    ctx.api.fail(
-        f'Argument 1 to "fields" has incompatible type "{format_type_bare(proper_type, ctx.api.options)}"; expected an attrs class',
-        ctx.context,
-    )
-
     return ctx.default_signature


Test patch:
diff --git a/test-data/unit/check-plugin-attrs.test b/test-data/unit/check-plugin-attrs.test
--- a/test-data/unit/check-plugin-attrs.test
+++ b/test-data/unit/check-plugin-attrs.test
@@ -1596,16 +1596,18 @@ def f(t: TA) -> None:
 [builtins fixtures/plugin_attrs.pyi]
 
 [case testNonattrsFields]
-# flags: --no-strict-optional
 from typing import Any, cast, Type
-from attrs import fields
+from attrs import fields, has
 
 class A:
     b: int
     c: str
 
-fields(A)  # E: Argument 1 to "fields" has incompatible type "Type[A]"; expected an attrs class
-fields(None)  # E: Argument 1 to "fields" has incompatible type "None"; expected an attrs class
+if has(A):
+    fields(A)
+else:
+    fields(A)  # E: Argument 1 to "fields" has incompatible type "Type[A]"; expected "Type[AttrsInstance]"
+fields(None)  # E: Argument 1 to "fields" has incompatible type "None"; expected "Type[AttrsInstance]"
 fields(cast(Any, 42))
 fields(cast(Type[Any], 43))
 
@@ -2167,7 +2169,8 @@ TA = TypeVar('TA', bound=A)
 TB = TypeVar('TB', bound=B)
 
 def f(b_or_t: TA | TB | int) -> None:
-    a2 = attrs.evolve(b_or_t)   # E: Argument 1 to "evolve" has type "Union[TA, TB, int]" whose item "TB" is not bound to an attrs class  # E: Argument 1 to "evolve" has incompatible type "Union[TA, TB, int]" whose item "int" is not an attrs class
+    a2 = attrs.evolve(b_or_t)  # E: Argument 1 to "evolve" has type "Union[TA, TB, int]" whose item "TB" is not bound to an attrs class \
+                               # E: Argument 1 to "evolve" has incompatible type "Union[TA, TB, int]" whose item "int" is not an attrs class
 
 
 [builtins fixtures/plugin_attrs.pyi]
@@ -2216,7 +2219,8 @@ def h(t: TNone) -> None:
     _ = attrs.evolve(t, x=42)  # E: Argument 1 to "evolve" has a variable type "TNone" not bound to an attrs class
 
 def x(t: TUnion) -> None:
-    _ = attrs.evolve(t, x=42)  # E: Argument 1 to "evolve" has incompatible type "TUnion" whose item "str" is not an attrs class  # E: Argument 1 to "evolve" has incompatible type "TUnion" whose item "int" is not an attrs class
+    _ = attrs.evolve(t, x=42)  # E: Argument 1 to "evolve" has incompatible type "TUnion" whose item "str" is not an attrs class \
+                               # E: Argument 1 to "evolve" has incompatible type "TUnion" whose item "int" is not an attrs class
 
 [builtins fixtures/plugin_attrs.pyi]