move to custom pytest plugin test runner, fix tests, add Any fallback to ForeignKey

This commit is contained in:
Maxim Kurnikov
2018-11-28 00:37:04 +03:00
parent f59cfe6371
commit 64bc053056
14 changed files with 451 additions and 366 deletions

View File

@@ -1,126 +1,95 @@
import typing
from typing import Optional, cast, Tuple, Any
from typing import Optional, cast
from django.apps.registry import Apps
from django.conf import Settings
from django.db import models
from mypy.checker import TypeChecker
from mypy.nodes import TypeInfo, SymbolTable, MDEF, AssignmentStmt, StrExpr
from mypy.nodes import SymbolTable, MDEF, AssignmentStmt
from mypy.plugin import FunctionContext, ClassDefContext
from mypy.types import Type, CallableType, Instance, AnyType
from mypy.types import Type, CallableType, Instance, AnyType, TypeOfAny
from mypy_django_plugin import helpers
def get_instance_type_for_class(klass: typing.Type[models.Model],
api: TypeChecker) -> Optional[Instance]:
model_qualname = helpers.get_obj_type_name(klass)
module_name, _, class_name = model_qualname.rpartition('.')
module = api.modules.get(module_name)
if not module or class_name not in module.names:
return
sym = module.names[class_name]
return Instance(sym.node, [])
def extract_to_value_type(ctx: FunctionContext,
apps: Optional[Apps]) -> Tuple[Optional[Instance], bool]:
api = cast(TypeChecker, ctx.api)
if 'to' not in ctx.arg_names:
return None, False
arg = ctx.args[ctx.arg_names.index('to')][0]
arg_type = ctx.arg_types[ctx.arg_names.index('to')][0]
if isinstance(arg_type, CallableType):
return arg_type.ret_type, False
if apps:
if isinstance(arg, StrExpr):
arg_value = arg.value
if '.' not in arg_value:
return None, False
app_label, modelname = arg_value.lower().split('.')
try:
model_cls = apps.get_model(app_label, modelname)
except LookupError:
# no model class found
return None, False
try:
instance = get_instance_type_for_class(model_cls, api=api)
if not instance:
return None, False
return instance, True
except AssertionError:
pass
return None, False
def extract_related_name_value(ctx: FunctionContext) -> str:
return ctx.context.args[ctx.context.arg_names.index('related_name')].value
return ctx.context.args[ctx.arg_names.index('related_name')].value
def add_new_class_member(klass_typeinfo: TypeInfo, name: str, new_member_instance: Instance) -> None:
klass_typeinfo.names[name] = helpers.create_new_symtable_node(name,
kind=MDEF,
instance=new_member_instance)
def reparametrize_with(instance: Instance, new_typevars: typing.List[Type]):
return Instance(instance.type, args=new_typevars)
def fill_typevars_with_any(instance: Instance) -> Type:
return reparametrize_with(instance, [AnyType(TypeOfAny.unannotated)])
def get_valid_to_value_or_none(ctx: FunctionContext) -> Optional[Instance]:
if 'to' not in ctx.arg_names:
# shouldn't happen, invalid code
ctx.api.msg.fail(f'to= parameter must be set for {ctx.context.callee.fullname}',
context=ctx.context)
return None
arg_type = ctx.arg_types[ctx.arg_names.index('to')][0]
if not isinstance(arg_type, CallableType):
ctx.api.msg.warn(f'to= parameter type {arg_type.__class__.__name__} is not supported',
context=ctx.context)
return None
referred_to_type = arg_type.ret_type
for base in referred_to_type.type.bases:
if base.type.fullname() == helpers.MODEL_CLASS_FULLNAME:
break
else:
ctx.api.msg.fail(f'to= parameter value must be '
f'a subclass of {helpers.MODEL_CLASS_FULLNAME}',
context=ctx.context)
return None
return referred_to_type
class ForeignKeyHook(object):
def __init__(self, settings: Settings, apps: Apps):
def __init__(self, settings: Settings):
self.settings = settings
self.apps = apps
def __call__(self, ctx: FunctionContext) -> Type:
api = cast(TypeChecker, ctx.api)
outer_class_info = api.tscope.classes[-1]
referred_to, is_string_based = extract_to_value_type(ctx, apps=self.apps)
if not referred_to:
return ctx.default_return_type
referred_to_type = get_valid_to_value_or_none(ctx)
if referred_to_type is None:
return fill_typevars_with_any(ctx.default_return_type)
if 'related_name' in ctx.context.arg_names:
if 'related_name' in ctx.arg_names:
related_name = extract_related_name_value(ctx)
queryset_type = api.named_generic_type(helpers.QUERYSET_CLASS_FULLNAME,
args=[Instance(outer_class_info, [])])
if isinstance(referred_to, AnyType):
return ctx.default_return_type
sym = helpers.create_new_symtable_node(related_name, MDEF,
instance=queryset_type)
referred_to_type.type.names[related_name] = sym
add_new_class_member(referred_to.type,
related_name, queryset_type)
if is_string_based:
return referred_to
return ctx.default_return_type
return reparametrize_with(ctx.default_return_type, [referred_to_type])
class OneToOneFieldHook(object):
def __init__(self, settings: Optional[Settings], apps: Optional[Apps]):
def __init__(self, settings: Optional[Settings]):
self.settings = settings
self.apps = apps
def __call__(self, ctx: FunctionContext) -> Type:
if 'related_name' not in ctx.context.arg_names:
return ctx.default_return_type
api = cast(TypeChecker, ctx.api)
outer_class_info = api.tscope.classes[-1]
referred_to, is_string_based = extract_to_value_type(ctx, apps=self.apps)
if referred_to is None:
return ctx.default_return_type
referred_to_type = get_valid_to_value_or_none(ctx)
if referred_to_type is None:
return fill_typevars_with_any(ctx.default_return_type)
if 'related_name' in ctx.context.arg_names:
if 'related_name' in ctx.arg_names:
related_name = extract_related_name_value(ctx)
outer_class_info = ctx.api.tscope.classes[-1]
add_new_class_member(referred_to.type, related_name,
new_member_instance=Instance(outer_class_info, []))
sym = helpers.create_new_symtable_node(related_name, MDEF,
instance=Instance(outer_class_info, []))
referred_to_type.type.names[related_name] = sym
if is_string_based:
return referred_to
return ctx.default_return_type
return reparametrize_with(ctx.default_return_type, [referred_to_type])
def set_fieldname_attrs_for_related_fields(ctx: ClassDefContext) -> None:

View File

@@ -1,32 +1,42 @@
from typing import cast
from typing import Optional, Any, cast
from django.conf import Settings
from mypy.nodes import MDEF
from mypy.nodes import Var, Context, GDEF
from mypy.options import Options
from mypy.plugin import ClassDefContext
from mypy.semanal import SemanticAnalyzerPass2
from mypy.types import Instance, AnyType, TypeOfAny
from mypy.types import Instance
from mypy_django_plugin import helpers
def add_settings_to_django_conf_object(ctx: ClassDefContext,
settings_module: str) -> Optional[Any]:
api = cast(SemanticAnalyzerPass2, ctx.api)
if settings_module not in api.modules:
return None
settings_file = api.modules[settings_module]
for name, sym in settings_file.names.items():
if name.isupper():
if not isinstance(sym.node, Var) or not isinstance(sym.type, Instance):
error_context = Context()
error_context.set_line(sym.node)
api.msg.fail("Need type annotation for '{}'".format(sym.node.name()),
context=error_context,
file=settings_file.path,
origin=Context())
continue
sym_copy = sym.copy()
sym_copy.node.info = sym_copy.type.type
sym_copy.kind = GDEF
ctx.cls.info.names[name] = sym_copy
class DjangoConfSettingsInitializerHook(object):
def __init__(self, settings: Settings):
self.settings = settings
def __init__(self, settings_module: str):
self.settings_module = settings_module
def __call__(self, ctx: ClassDefContext) -> None:
api = cast(SemanticAnalyzerPass2, ctx.api)
if self.settings:
for name, value in self.settings.__dict__.items():
if name.isupper():
if value is None:
# TODO: change to Optional[Any] later
ctx.cls.info.names[name] = helpers.create_new_symtable_node(name, MDEF,
instance=api.builtin_type('builtins.object'))
continue
if not self.settings_module:
return
type_fullname = helpers.get_obj_type_name(type(value))
sym = api.lookup_fully_qualified_or_none(type_fullname)
if sym is not None:
args = len(sym.node.type_vars) * [AnyType(TypeOfAny.from_omitted_generics)]
ctx.cls.info.names[name] = helpers.create_new_symtable_node(name, MDEF,
instance=Instance(sym.node, args))
add_settings_to_django_conf_object(ctx, self.settings_module)