Skip to content

Commit d53121b

Browse files
committed
add support for forms, values, values_list
1 parent 3c3122a commit d53121b

File tree

15 files changed

+594
-560
lines changed

15 files changed

+594
-560
lines changed

django-stubs/apps/registry.pyi

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ class Apps:
2323
def check_models_ready(self) -> None: ...
2424
def get_app_configs(self) -> Iterable[AppConfig]: ...
2525
def get_app_config(self, app_label: str) -> AppConfig: ...
26-
def get_models(self, include_auto_created: bool = ..., include_swapped: bool = ...) -> List[Type[Model]]: ...
27-
def get_model(self, app_label: str, model_name: Optional[str] = ..., require_ready: bool = ...) -> Type[Model]: ...
26+
# it's not possible to support it in plugin properly now
27+
def get_models(self, include_auto_created: bool = ..., include_swapped: bool = ...) -> List[Type[Any]]: ...
28+
def get_model(self, app_label: str, model_name: Optional[str] = ..., require_ready: bool = ...) -> Type[Any]: ...
2829
def register_model(self, app_label: str, model: Type[Model]) -> None: ...
2930
def is_installed(self, app_name: str) -> bool: ...
3031
def get_containing_app_config(self, object_name: str) -> Optional[AppConfig]: ...

django-stubs/db/models/sql/query.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ class Query:
100100
def resolve_expression(self, query: Query, *args: Any, **kwargs: Any) -> Query: ...
101101
def as_sql(self, compiler: SQLCompiler, connection: Any) -> Tuple[str, Tuple]: ...
102102
def resolve_lookup_value(self, value: Any, can_reuse: Optional[Set[str]], allow_joins: bool) -> Any: ...
103+
def solve_lookup_type(self, lookup: str) -> Tuple[Sequence[str], Sequence[str], bool]: ...
103104
def build_filter(
104105
self,
105106
filter_expr: Union[Dict[str, str], Tuple[str, Tuple[int, int]]],

mypy_django_plugin_newsemanal/django/context.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import os
22
from collections import defaultdict
33
from dataclasses import dataclass
4-
from typing import Any, Dict, Iterator, List, Optional, TYPE_CHECKING, Tuple, Type
4+
from typing import Any, Dict, Iterator, List, Optional, TYPE_CHECKING, Tuple, Type, Sequence
55

6+
from django.core.exceptions import FieldError
67
from django.db.models.base import Model
7-
from django.db.models.fields.related import ForeignKey
8+
from django.db.models.fields.related import ForeignKey, RelatedField
89
from django.utils.functional import cached_property
910
from mypy.checker import TypeChecker
1011
from mypy.types import Instance, Type as MypyType
@@ -13,6 +14,8 @@
1314
from django.contrib.postgres.fields import ArrayField
1415
from django.db.models.fields import CharField, Field
1516
from django.db.models.fields.reverse_related import ForeignObjectRel
17+
18+
from django.db.models.sql.query import Query
1619
from mypy_django_plugin_newsemanal.lib import helpers
1720

1821
if TYPE_CHECKING:
@@ -53,6 +56,9 @@ def noop_class_getitem(cls, key):
5356

5457

5558
class DjangoFieldsContext:
59+
def __init__(self, django_context: 'DjangoContext') -> None:
60+
self.django_context = django_context
61+
5662
def get_attname(self, field: Field) -> str:
5763
attname = field.attname
5864
return attname
@@ -81,11 +87,43 @@ def get_field_set_type(self, api: TypeChecker, field: Field, method: str) -> Myp
8187
field_set_type = helpers.convert_any_to_type(field_set_type, argument_field_type)
8288
return field_set_type
8389

90+
def get_field_get_type(self, api: TypeChecker, field: Field, method: str) -> MypyType:
91+
field_info = helpers.lookup_class_typeinfo(api, field.__class__)
92+
is_nullable = self.get_field_nullability(field, method)
93+
if isinstance(field, RelatedField):
94+
if method == 'values':
95+
primary_key_field = self.django_context.get_primary_key_field(field.related_model)
96+
return self.get_field_get_type(api, primary_key_field, method)
97+
98+
model_info = helpers.lookup_class_typeinfo(api, field.related_model)
99+
return Instance(model_info, [])
100+
else:
101+
return helpers.get_private_descriptor_type(field_info, '_pyi_private_get_type',
102+
is_nullable=is_nullable)
103+
104+
105+
class DjangoLookupsContext:
106+
def resolve_lookup(self, model_cls: Type[Model], lookup: str) -> Any:
107+
query = Query(model_cls)
108+
lookup_parts, field_parts, is_expression = query.solve_lookup_type(lookup)
109+
if lookup_parts:
110+
raise FieldError('Lookups not supported yet')
111+
112+
currently_observed_model = model_cls
113+
current_field = None
114+
for field_name in field_parts:
115+
current_field = currently_observed_model._meta.get_field(field_name)
116+
if isinstance(current_field, RelatedField):
117+
currently_observed_model = current_field.related_model
118+
119+
return current_field
120+
84121

85122
class DjangoContext:
86123
def __init__(self, plugin_toml_config: Optional[Dict[str, Any]]) -> None:
87124
self.config = DjangoPluginConfig()
88-
self.fields_context = DjangoFieldsContext()
125+
self.fields_context = DjangoFieldsContext(self)
126+
self.lookups_context = DjangoLookupsContext()
89127

90128
self.django_settings_module = None
91129
if plugin_toml_config:

mypy_django_plugin_newsemanal/lib/helpers.py

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1-
from typing import Dict, List, Optional, Set, Union
1+
from collections import OrderedDict
2+
from typing import Dict, List, Optional, Set, Union, Any
23

4+
from mypy import checker
35
from mypy.checker import TypeChecker
4-
from mypy.nodes import Expression, MypyFile, NameExpr, SymbolNode, TypeInfo, Var, SymbolTableNode
5-
from mypy.plugin import FunctionContext, MethodContext
6-
from mypy.types import AnyType, Instance, NoneTyp, Type as MypyType, TypeOfAny, UnionType
6+
from mypy.mro import calculate_mro
7+
from mypy.nodes import Block, ClassDef, Expression, GDEF, MDEF, MypyFile, NameExpr, SymbolNode, SymbolTable, SymbolTableNode, \
8+
TypeInfo, Var
9+
from mypy.plugin import CheckerPluginInterface, FunctionContext, MethodContext
10+
from mypy.types import AnyType, Instance, NoneTyp, TupleType, Type as MypyType, TypeOfAny, TypedDictType, UnionType
11+
12+
13+
def get_django_metadata(model_info: TypeInfo) -> Dict[str, Any]:
14+
return model_info.metadata.setdefault('django', {})
715

816

917
class IncompleteDefnException(Exception):
@@ -120,6 +128,53 @@ def get_nested_meta_node_for_current_class(info: TypeInfo) -> Optional[TypeInfo]
120128
return None
121129

122130

131+
def add_new_class_for_current_module(api: TypeChecker, name: str, bases: List[Instance],
132+
fields: 'OrderedDict[str, MypyType]') -> TypeInfo:
133+
current_module = api.scope.stack[0]
134+
new_class_unique_name = checker.gen_unique_name(name, current_module.names)
135+
136+
# make new class expression
137+
classdef = ClassDef(new_class_unique_name, Block([]))
138+
classdef.fullname = current_module.fullname() + '.' + new_class_unique_name
139+
140+
# make new TypeInfo
141+
new_typeinfo = TypeInfo(SymbolTable(), classdef, current_module.fullname())
142+
new_typeinfo.bases = bases
143+
calculate_mro(new_typeinfo)
144+
new_typeinfo.calculate_metaclass_type()
145+
146+
def add_field_to_new_typeinfo(var: Var, is_initialized_in_class: bool = False,
147+
is_property: bool = False) -> None:
148+
var.info = new_typeinfo
149+
var.is_initialized_in_class = is_initialized_in_class
150+
var.is_property = is_property
151+
var._fullname = new_typeinfo.fullname() + '.' + var.name()
152+
new_typeinfo.names[var.name()] = SymbolTableNode(MDEF, var)
153+
154+
# add fields
155+
var_items = [Var(item, typ) for item, typ in fields.items()]
156+
for var_item in var_items:
157+
add_field_to_new_typeinfo(var_item, is_property=True)
158+
159+
classdef.info = new_typeinfo
160+
current_module.names[new_class_unique_name] = SymbolTableNode(GDEF, new_typeinfo, plugin_generated=True)
161+
return new_typeinfo
162+
163+
164+
def make_oneoff_named_tuple(api: TypeChecker, name: str, fields: 'OrderedDict[str, MypyType]') -> TupleType:
165+
namedtuple_info = add_new_class_for_current_module(api, name,
166+
bases=[api.named_generic_type('typing.NamedTuple', [])],
167+
fields=fields)
168+
return TupleType(list(fields.values()), fallback=Instance(namedtuple_info, []))
169+
170+
171+
def make_tuple(api: 'TypeChecker', fields: List[MypyType]) -> TupleType:
172+
# fallback for tuples is any builtins.tuple instance
173+
fallback = api.named_generic_type('builtins.tuple',
174+
[AnyType(TypeOfAny.special_form)])
175+
return TupleType(fields, fallback=fallback)
176+
177+
123178
def convert_any_to_type(typ: MypyType, referred_to_type: MypyType) -> MypyType:
124179
if isinstance(typ, UnionType):
125180
converted_items = []
@@ -140,3 +195,10 @@ def convert_any_to_type(typ: MypyType, referred_to_type: MypyType) -> MypyType:
140195
return referred_to_type
141196

142197
return typ
198+
199+
200+
def make_typeddict(api: CheckerPluginInterface, fields: 'OrderedDict[str, MypyType]',
201+
required_keys: Set[str]) -> TypedDictType:
202+
object_type = api.named_generic_type('mypy_extensions._TypedDict', [])
203+
typed_dict_type = TypedDictType(fields, required_keys=required_keys, fallback=object_type)
204+
return typed_dict_type

mypy_django_plugin_newsemanal/lib/metadata.py

Lines changed: 0 additions & 27 deletions
This file was deleted.

mypy_django_plugin_newsemanal/main.py

Lines changed: 67 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import os
22
from functools import partial
3-
from typing import Callable, Dict, List, Optional, Tuple, Type
3+
from typing import Callable, Dict, List, Optional, Tuple
44

55
import toml
6+
from django.db.models.fields.related import RelatedField
67
from mypy.nodes import MypyFile, TypeInfo
78
from mypy.options import Options
8-
from mypy.plugin import ClassDefContext, FunctionContext, Plugin, MethodContext, AttributeContext
9+
from mypy.plugin import AttributeContext, ClassDefContext, FunctionContext, MethodContext, Plugin
910
from mypy.types import Type as MypyType
1011

11-
from django.db.models.fields.related import RelatedField
1212
from mypy_django_plugin_newsemanal.django.context import DjangoContext
13-
from mypy_django_plugin_newsemanal.lib import fullnames, metadata
14-
from mypy_django_plugin_newsemanal.transformers import fields, settings, querysets, init_create
13+
from mypy_django_plugin_newsemanal.lib import fullnames, helpers
14+
from mypy_django_plugin_newsemanal.transformers import fields, forms, init_create, querysets, settings
1515
from mypy_django_plugin_newsemanal.transformers.models import process_model_class
1616

1717

@@ -20,7 +20,7 @@ def transform_model_class(ctx: ClassDefContext,
2020
sym = ctx.api.lookup_fully_qualified_or_none(fullnames.MODEL_CLASS_FULLNAME)
2121

2222
if sym is not None and isinstance(sym.node, TypeInfo):
23-
metadata.get_django_metadata(sym.node)['model_bases'][ctx.cls.fullname] = 1
23+
helpers.get_django_metadata(sym.node)['model_bases'][ctx.cls.fullname] = 1
2424
else:
2525
if not ctx.api.final_iteration:
2626
ctx.api.defer()
@@ -29,10 +29,18 @@ def transform_model_class(ctx: ClassDefContext,
2929
process_model_class(ctx, django_context)
3030

3131

32+
def transform_form_class(ctx: ClassDefContext) -> None:
33+
sym = ctx.api.lookup_fully_qualified_or_none(fullnames.BASEFORM_CLASS_FULLNAME)
34+
if sym is not None and isinstance(sym.node, TypeInfo):
35+
helpers.get_django_metadata(sym.node)['baseform_bases'][ctx.cls.fullname] = 1
36+
37+
forms.make_meta_nested_class_inherit_from_any(ctx)
38+
39+
3240
def add_new_manager_base(ctx: ClassDefContext) -> None:
3341
sym = ctx.api.lookup_fully_qualified_or_none(fullnames.MANAGER_CLASS_FULLNAME)
3442
if sym is not None and isinstance(sym.node, TypeInfo):
35-
metadata.get_django_metadata(sym.node)['manager_bases'][ctx.cls.fullname] = 1
43+
helpers.get_django_metadata(sym.node)['manager_bases'][ctx.cls.fullname] = 1
3644

3745

3846
class NewSemanalDjangoPlugin(Plugin):
@@ -50,24 +58,34 @@ def __init__(self, options: Options) -> None:
5058
def _get_current_queryset_bases(self) -> Dict[str, int]:
5159
model_sym = self.lookup_fully_qualified(fullnames.QUERYSET_CLASS_FULLNAME)
5260
if model_sym is not None and isinstance(model_sym.node, TypeInfo):
53-
return (metadata.get_django_metadata(model_sym.node)
61+
return (helpers.get_django_metadata(model_sym.node)
5462
.setdefault('queryset_bases', {fullnames.QUERYSET_CLASS_FULLNAME: 1}))
5563
else:
5664
return {}
5765

5866
def _get_current_manager_bases(self) -> Dict[str, int]:
5967
model_sym = self.lookup_fully_qualified(fullnames.MANAGER_CLASS_FULLNAME)
6068
if model_sym is not None and isinstance(model_sym.node, TypeInfo):
61-
return (metadata.get_django_metadata(model_sym.node)
69+
return (helpers.get_django_metadata(model_sym.node)
6270
.setdefault('manager_bases', {fullnames.MANAGER_CLASS_FULLNAME: 1}))
6371
else:
6472
return {}
6573

6674
def _get_current_model_bases(self) -> Dict[str, int]:
6775
model_sym = self.lookup_fully_qualified(fullnames.MODEL_CLASS_FULLNAME)
6876
if model_sym is not None and isinstance(model_sym.node, TypeInfo):
69-
return metadata.get_django_metadata(model_sym.node).setdefault('model_bases',
70-
{fullnames.MODEL_CLASS_FULLNAME: 1})
77+
return helpers.get_django_metadata(model_sym.node).setdefault('model_bases',
78+
{fullnames.MODEL_CLASS_FULLNAME: 1})
79+
else:
80+
return {}
81+
82+
def _get_current_form_bases(self) -> Dict[str, int]:
83+
model_sym = self.lookup_fully_qualified(fullnames.BASEFORM_CLASS_FULLNAME)
84+
if model_sym is not None and isinstance(model_sym.node, TypeInfo):
85+
return (helpers.get_django_metadata(model_sym.node)
86+
.setdefault('baseform_bases', {fullnames.BASEFORM_CLASS_FULLNAME: 1,
87+
fullnames.FORM_CLASS_FULLNAME: 1,
88+
fullnames.MODELFORM_CLASS_FULLNAME: 1}))
7189
else:
7290
return {}
7391

@@ -85,15 +103,20 @@ def get_additional_deps(self, file: MypyFile) -> List[Tuple[int, str, int]]:
85103
if file.fullname() == 'django.conf' and self.django_context.django_settings_module:
86104
return [self._new_dependency(self.django_context.django_settings_module)]
87105

106+
# for values / values_list
107+
if file.fullname() == 'django.db.models':
108+
return [self._new_dependency('mypy_extensions'), self._new_dependency('typing')]
109+
88110
# for `get_user_model()`
89-
if file.fullname() == 'django.contrib.auth':
90-
auth_user_model_name = self.django_context.settings.AUTH_USER_MODEL
91-
try:
92-
auth_user_module = self.django_context.apps_registry.get_model(auth_user_model_name).__module__
93-
except LookupError:
94-
# get_user_model() model app is not installed
95-
return []
96-
return [self._new_dependency(auth_user_module)]
111+
if self.django_context.settings:
112+
if file.fullname() == 'django.contrib.auth':
113+
auth_user_model_name = self.django_context.settings.AUTH_USER_MODEL
114+
try:
115+
auth_user_module = self.django_context.apps_registry.get_model(auth_user_model_name).__module__
116+
except LookupError:
117+
# get_user_model() model app is not installed
118+
return []
119+
return [self._new_dependency(auth_user_module)]
97120

98121
# ensure that all mentioned to='someapp.SomeModel' are loaded with corresponding related Fields
99122
defined_model_classes = self.django_context.model_modules.get(file.fullname())
@@ -132,9 +155,29 @@ def get_function_hook(self, fullname: str
132155
return partial(init_create.redefine_and_typecheck_model_init, django_context=self.django_context)
133156

134157
def get_method_hook(self, fullname: str
135-
) -> Optional[Callable[[MethodContext], Type]]:
136-
manager_classes = self._get_current_manager_bases()
158+
) -> Optional[Callable[[MethodContext], MypyType]]:
137159
class_fullname, _, method_name = fullname.rpartition('.')
160+
if method_name == 'get_form_class':
161+
info = self._get_typeinfo_or_none(class_fullname)
162+
if info and info.has_base(fullnames.FORM_MIXIN_CLASS_FULLNAME):
163+
return forms.extract_proper_type_for_get_form_class
164+
165+
if method_name == 'get_form':
166+
info = self._get_typeinfo_or_none(class_fullname)
167+
if info and info.has_base(fullnames.FORM_MIXIN_CLASS_FULLNAME):
168+
return forms.extract_proper_type_for_get_form
169+
170+
if method_name == 'values':
171+
model_info = self._get_typeinfo_or_none(class_fullname)
172+
if model_info and model_info.has_base(fullnames.QUERYSET_CLASS_FULLNAME):
173+
return partial(querysets.extract_proper_type_queryset_values, django_context=self.django_context)
174+
175+
if method_name == 'values_list':
176+
model_info = self._get_typeinfo_or_none(class_fullname)
177+
if model_info and model_info.has_base(fullnames.QUERYSET_CLASS_FULLNAME):
178+
return partial(querysets.extract_proper_type_queryset_values_list, django_context=self.django_context)
179+
180+
manager_classes = self._get_current_manager_bases()
138181
if class_fullname in manager_classes and method_name == 'create':
139182
return partial(init_create.redefine_and_typecheck_model_create, django_context=self.django_context)
140183

@@ -146,19 +189,16 @@ def get_base_class_hook(self, fullname: str
146189
if fullname in self._get_current_manager_bases():
147190
return add_new_manager_base
148191

192+
if fullname in self._get_current_form_bases():
193+
return transform_form_class
194+
149195
def get_attribute_hook(self, fullname: str
150196
) -> Optional[Callable[[AttributeContext], MypyType]]:
151197
class_name, _, attr_name = fullname.rpartition('.')
152198
if class_name == fullnames.DUMMY_SETTINGS_BASE_CLASS:
153199
return partial(settings.get_type_of_settings_attribute,
154200
django_context=self.django_context)
155201

156-
# def get_type_analyze_hook(self, fullname: str
157-
# ) -> Optional[Callable[[AnalyzeTypeContext], MypyType]]:
158-
# queryset_bases = self._get_current_queryset_bases()
159-
# if fullname in queryset_bases:
160-
# return partial(querysets.set_first_generic_param_as_default_for_second, fullname=fullname)
161-
162202

163203
def plugin(version):
164204
return NewSemanalDjangoPlugin

0 commit comments

Comments
 (0)