Repository: python/mypy

Issue:
attrs plugin bug when subclassing frozen classes
Running into an internal error when type checking the following attrs code:

```python
import attr


@attr.s(frozen=True, auto_attribs=True)
class A:
    foo: int
    bar: str


@attr.s(frozen=True, auto_attribs=True)
class B(A):
    baz: int
```

This code when run works as expected:

```python

>>> thing = B(foo=1, bar='hi', baz='bye')
>>> thing.foo = 2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/jliu/Projects/lyft/coupons/venv/lib/python3.6/site-packages/attr/_make.py", line 351, in _frozen_setattrs
    raise FrozenInstanceError()
attr.exceptions.FrozenInstanceError
```

Running mypy on this code yields the following error:
```
$ mypy -s --strict-optional --warn-no-return --show-traceback --html-report build app/test.py

Warning: --silent-imports has been replaced by --ignore-missing-imports --follow-imports=skip
app/test.py:10: error: INTERNAL ERROR -- please report a bug at https://github.com/python/mypy/issues version: 0.570-dev-064c8d3301f8fab0769f12f4b1bb04a7fd7ae0de
Traceback (most recent call last):
  File "/usr/local/bin/mypy", line 11, in <module>
    load_entry_point('mypy===0.570-dev-064c8d3301f8fab0769f12f4b1bb04a7fd7ae0de', 'console_scripts', 'mypy')()
  File "/usr/local/lib/python3.6/site-packages/mypy/__main__.py", line 7, in console_entry
    main(None)
  File "/usr/local/lib/python3.6/site-packages/mypy/main.py", line 80, in main
    type_check_only(sources, bin_dir, options, flush_errors)
  File "/usr/local/lib/python3.6/site-packages/mypy/main.py", line 129, in type_check_only
    flush_errors=flush_errors)
  File "/usr/local/lib/python3.6/site-packages/mypy/build.py", line 180, in build
    result = _build(sources, options, alt_lib_path, bin_dir, saved_cache, flush_errors)
  File "/usr/local/lib/python3.6/site-packages/mypy/build.py", line 266, in _build
    graph = dispatch(sources, manager)
  File "/usr/local/lib/python3.6/site-packages/mypy/build.py", line 2112, in dispatch
    process_graph(graph, manager)
  File "/usr/local/lib/python3.6/site-packages/mypy/build.py", line 2413, in process_graph
    process_stale_scc(graph, scc, manager)
  File "/usr/local/lib/python3.6/site-packages/mypy/build.py", line 2589, in process_stale_scc
    graph[id].semantic_analysis()
  File "/usr/local/lib/python3.6/site-packages/mypy/build.py", line 1947, in semantic_analysis
    self.manager.semantic_analyzer.visit_file(self.tree, self.xpath, self.options, patches)
  File "/usr/local/lib/python3.6/site-packages/mypy/semanal.py", line 294, in visit_file
    self.accept(d)
  File "/usr/local/lib/python3.6/site-packages/mypy/semanal.py", line 3962, in accept
    node.accept(self)
  File "/usr/local/lib/python3.6/site-packages/mypy/nodes.py", line 711, in accept
    return visitor.visit_class_def(self)
  File "/usr/local/lib/python3.6/site-packages/mypy/semanal.py", line 679, in visit_class_def
    self.scope.leave()
  File "/usr/local/Cellar/python3/3.6.1/Frameworks/Python.framework/Versions/3.6/lib/python3.6/contextlib.py", line 89, in __exit__
    next(self.gen)
  File "/usr/local/lib/python3.6/site-packages/mypy/semanal.py", line 735, in analyze_class_body
    self.apply_class_plugin_hooks(defn)
  File "/usr/local/lib/python3.6/site-packages/mypy/semanal.py", line 761, in apply_class_plugin_hooks
    hook(ClassDefContext(defn, decorator, self))
  File "/usr/local/lib/python3.6/site-packages/mypy/plugins/attrs.py", line 155, in attr_class_maker_callback
    _make_frozen(ctx, attributes)
  File "/usr/local/lib/python3.6/site-packages/mypy/plugins/attrs.py", line 389, in _make_frozen
    node = ctx.cls.info.names[attribute.name].node
KeyError: 'foo'
app/test.py:10: : note: use --pdb to drop into pdb
```

@euresti I believe you worked on this recently so tagging you (thanks for getting this in btw!)


Discussion:
Eek. Looks like I added to TODO: https://github.com/python/mypy/blob/master/mypy/plugins/attrs.py#L387

I didn't think it would crash though.  Sorry.
No worries, it's not a requirement for me to subclass for now so all is well. Better to have this typechecked at all in the first place 😄 

Resolving patch:
diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py
--- a/mypy/plugins/attrs.py
+++ b/mypy/plugins/attrs.py
@@ -131,7 +131,7 @@ def attr_class_maker_callback(ctx: 'mypy.plugin.ClassDefContext',
     info = ctx.cls.info
 
     init = _get_decorator_bool_argument(ctx, 'init', True)
-    frozen = _get_decorator_bool_argument(ctx, 'frozen', False)
+    frozen = _get_frozen(ctx)
     cmp = _get_decorator_bool_argument(ctx, 'cmp', True)
     auto_attribs = _get_decorator_bool_argument(ctx, 'auto_attribs', auto_attribs_default)
 
@@ -146,6 +146,12 @@ def attr_class_maker_callback(ctx: 'mypy.plugin.ClassDefContext',
 
     attributes = _analyze_class(ctx, auto_attribs)
 
+    # Save the attributes so that subclasses can reuse them.
+    ctx.cls.info.metadata['attrs'] = {
+        'attributes': [attr.serialize() for attr in attributes],
+        'frozen': frozen,
+    }
+
     adder = MethodAdder(info, ctx.api.named_type('__builtins__.function'))
     if init:
         _add_init(ctx, attributes, adder)
@@ -155,6 +161,17 @@ def attr_class_maker_callback(ctx: 'mypy.plugin.ClassDefContext',
         _make_frozen(ctx, attributes)
 
 
+def _get_frozen(ctx: 'mypy.plugin.ClassDefContext') -> bool:
+    """Return whether this class is frozen."""
+    if _get_decorator_bool_argument(ctx, 'frozen', False):
+        return True
+    # Subclasses of frozen classes are frozen so check that.
+    for super_info in ctx.cls.info.mro[1:-1]:
+        if 'attrs' in super_info.metadata and super_info.metadata['attrs']['frozen']:
+            return True
+    return False
+
+
 def _analyze_class(ctx: 'mypy.plugin.ClassDefContext', auto_attribs: bool) -> List[Attribute]:
     """Analyze the class body of an attr maker, its parents, and return the Attributes found."""
     own_attrs = OrderedDict()  # type: OrderedDict[str, Attribute]
@@ -172,6 +189,14 @@ def _analyze_class(ctx: 'mypy.plugin.ClassDefContext', auto_attribs: bool) -> Li
         elif isinstance(stmt, Decorator):
             _cleanup_decorator(stmt, own_attrs)
 
+    for attribute in own_attrs.values():
+        # Even though these look like class level assignments we want them to look like
+        # instance level assignments.
+        if attribute.name in ctx.cls.info.names:
+            node = ctx.cls.info.names[attribute.name].node
+            assert isinstance(node, Var)
+            node.is_initialized_in_class = False
+
     # Traverse the MRO and collect attributes from the parents.
     taken_attr_names = set(own_attrs)
     super_attrs = []
@@ -186,9 +211,6 @@ def _analyze_class(ctx: 'mypy.plugin.ClassDefContext', auto_attribs: bool) -> Li
                     taken_attr_names.add(a.name)
     attributes = super_attrs + list(own_attrs.values())
 
-    # Save the attributes so that subclasses can reuse them.
-    ctx.cls.info.metadata['attrs'] = {'attributes': [attr.serialize() for attr in attributes]}
-
     # Check the init args for correct default-ness.  Note: This has to be done after all the
     # attributes for all classes have been read, because subclasses can override parents.
     last_default = False
@@ -384,12 +406,20 @@ def _add_cmp(ctx: 'mypy.plugin.ClassDefContext', adder: 'MethodAdder') -> None:
 
 def _make_frozen(ctx: 'mypy.plugin.ClassDefContext', attributes: List[Attribute]) -> None:
     """Turn all the attributes into properties to simulate frozen classes."""
-    # TODO: Handle subclasses of frozen classes.
     for attribute in attributes:
-        node = ctx.cls.info.names[attribute.name].node
-        assert isinstance(node, Var)
-        node.is_initialized_in_class = False
-        node.is_property = True
+        if attribute.name in ctx.cls.info.names:
+            # This variable belongs to this class so we can modify it.
+            node = ctx.cls.info.names[attribute.name].node
+            assert isinstance(node, Var)
+            node.is_property = True
+        else:
+            # This variable belongs to a super class so create new Var so we
+            # can modify it.
+            var = Var(attribute.name, ctx.cls.info[attribute.name].type)
+            var.info = ctx.cls.info
+            var._fullname = '%s.%s' % (ctx.cls.info.fullname(), var.name())
+            ctx.cls.info.names[var.name()] = SymbolTableNode(MDEF, var)
+            var.is_property = True
 
 
 def _add_init(ctx: 'mypy.plugin.ClassDefContext', attributes: List[Attribute],


Test patch:
diff --git a/test-data/unit/check-attr.test b/test-data/unit/check-attr.test
--- a/test-data/unit/check-attr.test
+++ b/test-data/unit/check-attr.test
@@ -705,3 +705,69 @@ import attr
 class A(object):
     x = attr.ib()
 [builtins_py2 fixtures/bool.pyi]
+
+[case testAttrsFrozenSubclass]
+import attr
+
+@attr.dataclass
+class NonFrozenBase:
+    a: int
+
+@attr.dataclass(frozen=True)
+class FrozenBase:
+    a: int
+
+@attr.dataclass(frozen=True)
+class FrozenNonFrozen(NonFrozenBase):
+    b: int
+
+@attr.dataclass(frozen=True)
+class FrozenFrozen(FrozenBase):
+    b: int
+
+@attr.dataclass
+class NonFrozenFrozen(FrozenBase):
+    b: int
+
+# Make sure these are untouched
+non_frozen_base = NonFrozenBase(1)
+non_frozen_base.a = 17
+frozen_base = FrozenBase(1)
+frozen_base.a = 17  # E: Property "a" defined in "FrozenBase" is read-only
+
+a = FrozenNonFrozen(1, 2)
+a.a = 17  # E: Property "a" defined in "FrozenNonFrozen" is read-only
+a.b = 17  # E: Property "b" defined in "FrozenNonFrozen" is read-only
+
+b = FrozenFrozen(1, 2)
+b.a = 17  # E: Property "a" defined in "FrozenFrozen" is read-only
+b.b = 17  # E: Property "b" defined in "FrozenFrozen" is read-only
+
+c = NonFrozenFrozen(1, 2)
+c.a = 17  # E: Property "a" defined in "NonFrozenFrozen" is read-only
+c.b = 17  # E: Property "b" defined in "NonFrozenFrozen" is read-only
+
+[builtins fixtures/bool.pyi]
+[case testAttrsCallableAttributes]
+from typing import Callable
+import attr
+def blah(a: int, b: int) -> bool:
+    return True
+
+@attr.s(auto_attribs=True)
+class F:
+    _cb: Callable[[int, int], bool] = blah
+    def foo(self) -> bool:
+        return self._cb(5, 6)
+
+@attr.s
+class G:
+    _cb: Callable[[int, int], bool] = attr.ib(blah)
+    def foo(self) -> bool:
+        return self._cb(5, 6)
+
+@attr.s(auto_attribs=True, frozen=True)
+class FFrozen(F):
+    def bar(self) -> bool:
+        return self._cb(5, 6)
+[builtins fixtures/callable.pyi]