Repository: python/mypy
Pull request: Fix `AttrsInstance` protocol check with cache
Use correct fullname for `__attrs_attrs__` ClassVar to fix issue with warm cache.
Closes #14099


Problem statement:
attrs plugin fails to type check against AttrsInstance protocol with warm cache
**Bug Report**

Program using protocol for attrs instances fails to typecheck once the mypy cache is warm. 

**To Reproduce**

Given a virtualenv containing:
```bash
$ pip freeze
attrs==22.1.0
mypy==0.991
mypy-extensions==0.4.3
tomli==2.0.1
typing_extensions==4.4.0
```

a.py:
```python
import attr
from typing import Protocol, ClassVar, Any

class AttrsInstance(Protocol):
    """
    A protocol to be able to statically accept an attrs class.
    attr itself defines AttrsInstance but only at type checking time.
    """

    __attrs_attrs__: ClassVar[Any]


@attr.s
class Foo:
    bar: str = attr.ib()
```

b.py:
```python
from a import AttrsInstance, Foo
from typing import Type

thing: Type[AttrsInstance] = Foo 
print(thing)
```

First try with cold cache:
```bash
$ rm -rf .mypy_cache/  # Make sure the cache is cold
$ mypy a.py b.py
Success: no issues found in 2 source files
```

Now edit b.py adding one line at the end for example:
```python
from a import AttrsInstance, Foo
from typing import Type

thing: Type[AttrsInstance] = Foo 
print(thing)

foo = False
```

And re-run mypy, getting error unrelated to the change!
```bash
$ mypy a.py b.py
b.py:4: error: Incompatible types in assignment (expression has type "Type[Foo]", variable has type "Type[AttrsInstance]")  [assignment]
Found 1 error in 1 file (checked 2 source files)
```

Deleting the cache makes it work again:
```bash
$ rm -r .mypy_cache/
$ mypy a.py b.py
```

Note that we also get an error if using `AttrsInstance` directly instead of `Type[AttrsInstance]`:

Using this b.py:
```Python
from a import AttrsInstance, Foo
from typing import Type

thing: AttrsInstance = Foo("bar")
print(thing)
```

Results in this after a similar edit to b.py:
```
(virtualenv) thomas@thomas-xps13:~/Projects/test$ mypy a.py b.py
b.py:4: error: Incompatible types in assignment (expression has type "Foo", variable has type "AttrsInstance")  [assignment]
b.py:4: note: Protocol member AttrsInstance.__attrs_attrs__ expected class variable, got instance variable
b.py:4: note: Protocol member AttrsInstance.__attrs_attrs__ expected settable variable, got read-only attribute
Found 1 error in 1 file (checked 2 source files)
```

**Expected Behavior**

Program should still typecheck correctly with warm cache.

**Actual Behavior**

Program fails to typecheck. Deleting the cache and re-running works as a (slow) workaround.

**Your Environment**

<!-- Include as many relevant details about the environment you experienced the bug in -->

- Mypy version used: 0.991
- Mypy command-line flags: mypy a.py b.py
- Mypy configuration options from `mypy.ini` (and other config files): none
- Python version used: 3.10.6 (ubuntu 22.04, package version 3.10.6-1~22.04.1)

Hints from issue discussion:

Commit message: Fix AttrsInstance protocol check with cache

Patch to review:
diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py
index 16e8891e5f57..50d2955d2584 100644
--- a/mypy/plugins/attrs.py
+++ b/mypy/plugins/attrs.py
@@ -828,7 +828,7 @@ def _add_attrs_magic_attribute(
         ctx.cls,
         MAGIC_ATTR_NAME,
         TupleType(attributes_types, fallback=attributes_type),
-        fullname=f"{ctx.cls.fullname}.{attr_name}",
+        fullname=f"{ctx.cls.fullname}.{MAGIC_ATTR_NAME}",
         override_allow_incompatible=True,
         is_classvar=True,
     )
diff --git a/test-data/unit/fine-grained-attr.test b/test-data/unit/fine-grained-attr.test
index fd7c97da0662..96392b392ddd 100644
--- a/test-data/unit/fine-grained-attr.test
+++ b/test-data/unit/fine-grained-attr.test
@@ -46,3 +46,32 @@ A.__attrs_attrs__.b
 
 [out]
 ==
+
+[case magicAttributeConsistency2]
+import m
+
+[file c.py]
+import attr
+
+@attr.s
+class Entry:
+    var: int
+[builtins fixtures/attr.pyi]
+
+[file m.py]
+import attr
+from c import Entry
+
+def func(e: Entry) -> None:
+    attr.asdict(e)
+
+[file m.py.2]
+import attr
+from c import Entry
+
+def func(e: Entry) -> int:
+    attr.asdict(e)
+    return 2
+
+[out]
+==
diff --git a/test-data/unit/lib-stub/attr/__init__.pyi b/test-data/unit/lib-stub/attr/__init__.pyi
index 795e5d3f4f69..e439e0ebfd1a 100644
--- a/test-data/unit/lib-stub/attr/__init__.pyi
+++ b/test-data/unit/lib-stub/attr/__init__.pyi
@@ -1,4 +1,19 @@
-from typing import TypeVar, overload, Callable, Any, Type, Optional, Union, Sequence, Mapping, Generic
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    Generic,
+    Mapping,
+    Optional,
+    Protocol,
+    Sequence,
+    Type,
+    TypeVar,
+    Union,
+    overload,
+)
+
+from ._typing_compat import AttrsInstance_
 
 _T = TypeVar('_T')
 _C = TypeVar('_C', bound=type)
@@ -8,6 +23,9 @@ _ConverterType = Callable[[Any], _T]
 _FilterType = Callable[[Any, Any], bool]
 _ValidatorArgType = Union[_ValidatorType[_T], Sequence[_ValidatorType[_T]]]
 
+class AttrsInstance(AttrsInstance_, Protocol):
+    pass
+
 # This form catches explicit None or no default but with no other arguments returns Any.
 @overload
 def attrib(default: None = ...,
@@ -244,3 +262,15 @@ def field(
     order: Optional[bool] = ...,
     on_setattr: Optional[object] = ...,
 ) -> Any: ...
+
+def asdict(
+    inst: AttrsInstance,
+    recurse: bool = ...,
+    filter: Optional[_FilterType] = ...,
+    dict_factory: Type[Mapping[Any, Any]] = ...,
+    retain_collection_types: bool = ...,
+    value_serializer: Optional[
+        Callable[[type, Attribute[Any], Any], Any]
+    ] = ...,
+    tuple_keys: Optional[bool] = ...,
+) -> Dict[str, Any]: ...
diff --git a/test-data/unit/lib-stub/attr/_typing_compat.py b/test-data/unit/lib-stub/attr/_typing_compat.py
new file mode 100644
index 000000000000..8140c804f7dc
--- /dev/null
+++ b/test-data/unit/lib-stub/attr/_typing_compat.py
@@ -0,0 +1,5 @@
+from typing import Any, ClassVar, Protocol
+
+
+class AttrsInstance_(Protocol):
+    __attrs_attrs__: ClassVar[Any]


Code review comments:
diff_hunk: @@ -46,3 +46,32 @@ A.__attrs_attrs__.b
 
 [out]
 ==
+
+[case magicAttributeConsistency2]
+import m
+
+[file c.py]
+import attr
+
+@attr.s
+class Entry:
+    var: int
+[builtins fixtures/attr.pyi]
+
+[file m.py]
+import attr
+from c import Entry
+
+def func(e: Entry) -> None:
+    attr.asdict(e)
+
+[file m.py.2]
+import attr
+from c import Entry
+
+def func(e: Entry) -> int:
+    attr.asdict(e)
+    return 2
path: test-data/unit/fine-grained-attr.test
text: @author:
The fix does work when tested locally. However, I couldn't reproduce it with this test case although I though I should be able to. Maybe the cache isn't serialized for it?

Open to ideas how to fix the test case.

@user1:
I was able to reproduce with `types-attrs`. Maybe try revendoring the attr fixture from typeshed from before https://github.com/python/typeshed/pull/5585 ?

@author:
I believe we already use a custom stub for the test from `test-data/unit/lib-stub/attr`. That shouldn't be the issue, I think.

Rather the problem only happens if `c.py` is deserialized from cache and I'm not sure that is happing here. Might be that just both `c.py` and `m.py.2` get analyzed in the second run in which case the error won't show up.

@author:
@user1 Got it working 🎉 Moving `AttrsInstance` from `lib-stub` to the test file did the job. Without the change in `plugins/attrs.py`, the test case is now failing.