Skip to content

Commit

Permalink
Plugin to typecheck attrs-generated classes (#4397)
Browse files Browse the repository at this point in the history
See http://www.attrs.org/en/stable/how-does-it-work.html for
information on how attrs works.

The plugin walks the class declaration (including superclasses) looking
for "attributes" then depending on how the decorator was called, makes
modification to the classes as follows:
* init=True adds an __init__ method.
* cmp=True adds all of the necessary __cmp__ methods.
* frozen=True turns all attributes into properties to make the class read only.
* Remove any @x.default and @y.validator decorators which are only part
of class creation.

Fixes #2088
  • Loading branch information
euresti authored and ilevkivskyi committed Feb 13, 2018
1 parent b993693 commit 91f2d36
Show file tree
Hide file tree
Showing 12 changed files with 1,611 additions and 3 deletions.
14 changes: 14 additions & 0 deletions mypy/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1993,6 +1993,10 @@ class is generic then it will be a type constructor of higher kind.
# needed during the semantic passes.)
replaced = None # type: TypeInfo

# This is a dictionary that will be serialized and un-serialized as is.
# It is useful for plugins to add their data to save in the cache.
metadata = None # type: Dict[str, JsonDict]

FLAGS = [
'is_abstract', 'is_enum', 'fallback_to_any', 'is_named_tuple',
'is_newtype', 'is_protocol', 'runtime_protocol'
Expand All @@ -2016,6 +2020,7 @@ def __init__(self, names: 'SymbolTable', defn: ClassDef, module_name: str) -> No
self._cache = set()
self._cache_proper = set()
self.add_type_vars()
self.metadata = {}

def add_type_vars(self) -> None:
if self.defn.type_vars:
Expand Down Expand Up @@ -2218,6 +2223,7 @@ def serialize(self) -> JsonDict:
'typeddict_type':
None if self.typeddict_type is None else self.typeddict_type.serialize(),
'flags': get_flags(self, TypeInfo.FLAGS),
'metadata': self.metadata,
}
return data

Expand All @@ -2244,6 +2250,7 @@ def deserialize(cls, data: JsonDict) -> 'TypeInfo':
else mypy.types.TupleType.deserialize(data['tuple_type']))
ti.typeddict_type = (None if data['typeddict_type'] is None
else mypy.types.TypedDictType.deserialize(data['typeddict_type']))
ti.metadata = data['metadata']
set_flags(ti, data['flags'])
return ti

Expand Down Expand Up @@ -2612,3 +2619,10 @@ def check_arg_names(names: Sequence[Optional[str]], nodes: List[T], fail: Callab
fail("Duplicate argument '{}' in {}".format(name, description), node)
break
seen_names.add(name)


def is_class_var(expr: NameExpr) -> bool:
"""Return whether the expression is ClassVar[...]"""
if isinstance(expr.node, Var):
return expr.node.is_classvar
return False
41 changes: 38 additions & 3 deletions mypy/plugin.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
"""Plugin system for extending mypy."""

from collections import OrderedDict
from abc import abstractmethod
from functools import partial
from typing import Callable, List, Tuple, Optional, NamedTuple, TypeVar

from mypy.nodes import Expression, StrExpr, IntExpr, UnaryExpr, Context, DictExpr, ClassDef
import mypy.plugins.attrs
from mypy.nodes import (
Expression, StrExpr, IntExpr, UnaryExpr, Context, DictExpr, ClassDef,
TypeInfo, SymbolTableNode
)
from mypy.tvar_scope import TypeVarScope
from mypy.types import (
Type, Instance, CallableType, TypedDictType, UnionType, NoneTyp, FunctionLike, TypeVarType,
Type, Instance, CallableType, TypedDictType, UnionType, NoneTyp, TypeVarType,
AnyType, TypeList, UnboundType, TypeOfAny
)
from mypy.messages import MessageBuilder
Expand Down Expand Up @@ -56,6 +61,9 @@ def named_generic_type(self, name: str, args: List[Type]) -> Instance:
class SemanticAnalyzerPluginInterface:
"""Interface for accessing semantic analyzer functionality in plugins."""

options = None # type: Options
msg = None # type: MessageBuilder

@abstractmethod
def named_type(self, qualified_name: str, args: Optional[List[Type]] = None) -> Instance:
raise NotImplementedError
Expand All @@ -69,6 +77,22 @@ def fail(self, msg: str, ctx: Context, serious: bool = False, *,
blocker: bool = False) -> None:
raise NotImplementedError

@abstractmethod
def anal_type(self, t: Type, *,
tvar_scope: Optional[TypeVarScope] = None,
allow_tuple_literal: bool = False,
aliasing: bool = False,
third_pass: bool = False) -> Type:
raise NotImplementedError

@abstractmethod
def class_type(self, info: TypeInfo) -> Type:
raise NotImplementedError

@abstractmethod
def lookup_fully_qualified(self, name: str) -> SymbolTableNode:
raise NotImplementedError


# A context for a function hook that infers the return type of a function with
# a special signature.
Expand Down Expand Up @@ -262,6 +286,17 @@ def get_method_hook(self, fullname: str
return int_pow_callback
return None

def get_class_decorator_hook(self, fullname: str
) -> Optional[Callable[[ClassDefContext], None]]:
if fullname in mypy.plugins.attrs.attr_class_makers:
return mypy.plugins.attrs.attr_class_maker_callback
elif fullname in mypy.plugins.attrs.attr_dataclass_makers:
return partial(
mypy.plugins.attrs.attr_class_maker_callback,
auto_attribs_default=True
)
return None


def open_callback(ctx: FunctionContext) -> Type:
"""Infer a better return type for 'open'.
Expand Down
Empty file added mypy/plugins/__init__.py
Empty file.
Loading

0 comments on commit 91f2d36

Please sign in to comment.