Skip to content

Commit 830d74b

Browse files
authored
Add support for inline from_queryset in model classes (#1045)
* Add support for inline from_queryset in model classes This adds support for calling <Manager>.from_queryset(<QuerySet>)() inline in models, for example like this: class MyModel(models.Model): objects = MyManager.from_queryset(MyQuerySet)() This is done by inspecting the class body in the transform_class_hook * Fix missing methods on copied manager * Add test and other minor tweaks * Always create manager at module level When the manager is added at the class level, which happened when it was created inline in the model body, it's not possible to retrieve the manager again based on fullname. That lead to problems with inheritance and the default manager.
1 parent 2e84c03 commit 830d74b

File tree

7 files changed

+244
-179
lines changed

7 files changed

+244
-179
lines changed

mypy_django_plugin/lib/helpers.py

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,23 @@ def is_annotated_model_fullname(model_cls_fullname: str) -> bool:
208208
return model_cls_fullname.startswith(WITH_ANNOTATIONS_FULLNAME + "[")
209209

210210

211+
def create_type_info(name: str, module: str, bases: List[Instance]) -> TypeInfo:
212+
213+
# make new class expression
214+
classdef = ClassDef(name, Block([]))
215+
classdef.fullname = module + "." + name
216+
217+
# make new TypeInfo
218+
new_typeinfo = TypeInfo(SymbolTable(), classdef, module)
219+
new_typeinfo.bases = bases
220+
calculate_mro(new_typeinfo)
221+
new_typeinfo.calculate_metaclass_type()
222+
223+
classdef.info = new_typeinfo
224+
225+
return new_typeinfo
226+
227+
211228
def add_new_class_for_module(
212229
module: MypyFile,
213230
name: str,
@@ -217,15 +234,7 @@ def add_new_class_for_module(
217234
) -> TypeInfo:
218235
new_class_unique_name = checker.gen_unique_name(name, module.names)
219236

220-
# make new class expression
221-
classdef = ClassDef(new_class_unique_name, Block([]))
222-
classdef.fullname = module.fullname + "." + new_class_unique_name
223-
224-
# make new TypeInfo
225-
new_typeinfo = TypeInfo(SymbolTable(), classdef, module.fullname)
226-
new_typeinfo.bases = bases
227-
calculate_mro(new_typeinfo)
228-
new_typeinfo.calculate_metaclass_type()
237+
new_typeinfo = create_type_info(new_class_unique_name, module.fullname, bases)
229238

230239
# add fields
231240
if fields:
@@ -237,7 +246,6 @@ def add_new_class_for_module(
237246
MDEF, var, plugin_generated=True, no_serialize=no_serialize
238247
)
239248

240-
classdef.info = new_typeinfo
241249
module.names[new_class_unique_name] = SymbolTableNode(
242250
GDEF, new_typeinfo, plugin_generated=True, no_serialize=no_serialize
243251
)
@@ -382,29 +390,25 @@ def copy_method_to_another_class(
382390
method_node: FuncDef,
383391
return_type: Optional[MypyType] = None,
384392
original_module_name: Optional[str] = None,
385-
) -> None:
393+
) -> bool:
386394
semanal_api = get_semanal_api(ctx)
387395
if method_node.type is None:
388-
if not semanal_api.final_iteration:
389-
semanal_api.defer()
390-
return
391-
392396
arguments, return_type = build_unannotated_method_args(method_node)
393397
add_method_to_class(
394398
semanal_api, ctx.cls, new_method_name, args=arguments, return_type=return_type, self_type=self_type
395399
)
396-
return
400+
return True
397401

398402
method_type = method_node.type
399403
if not isinstance(method_type, CallableType):
400404
if not semanal_api.final_iteration:
401405
semanal_api.defer()
402-
return
406+
return False
403407

404408
if return_type is None:
405409
return_type = bind_or_analyze_type(method_type.ret_type, semanal_api, original_module_name)
406410
if return_type is None:
407-
return
411+
return False
408412

409413
# We build the arguments from the method signature (`CallableType`), because if we were to
410414
# use the arguments from the method node (`FuncDef.arguments`) we're not compatible with
@@ -417,7 +421,7 @@ def copy_method_to_another_class(
417421
):
418422
bound_arg_type = bind_or_analyze_type(arg_type, semanal_api, original_module_name)
419423
if bound_arg_type is None:
420-
return
424+
return False
421425
if arg_name is None and hasattr(method_node, "arguments"):
422426
arg_name = method_node.arguments[pos].variable.name
423427
arguments.append(
@@ -435,6 +439,8 @@ def copy_method_to_another_class(
435439
semanal_api, ctx.cls, new_method_name, args=arguments, return_type=return_type, self_type=self_type
436440
)
437441

442+
return True
443+
438444

439445
def add_new_manager_base(api: SemanticAnalyzerPluginInterface, fullname: str) -> None:
440446
sym = api.lookup_fully_qualified_or_none(fullnames.MANAGER_CLASS_FULLNAME)

mypy_django_plugin/main.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
from mypy_django_plugin.transformers import fields, forms, init_create, meta, querysets, request, settings
2525
from mypy_django_plugin.transformers.managers import (
2626
create_new_manager_class_from_from_queryset_method,
27-
fail_if_manager_type_created_in_model_body,
2827
resolve_manager_method,
2928
)
3029
from mypy_django_plugin.transformers.models import (
@@ -237,11 +236,6 @@ def get_method_hook(self, fullname: str) -> Optional[Callable[[MethodContext], M
237236
django_context=self.django_context,
238237
)
239238

240-
elif method_name == "from_queryset":
241-
info = self._get_typeinfo_or_none(class_fullname)
242-
if info and info.has_base(fullnames.BASE_MANAGER_CLASS_FULLNAME):
243-
return fail_if_manager_type_created_in_model_body
244-
245239
return None
246240

247241
def get_base_class_hook(self, fullname: str) -> Optional[Callable[[ClassDefContext], None]]:

mypy_django_plugin/transformers/managers.py

Lines changed: 94 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,19 @@
88
FuncBase,
99
FuncDef,
1010
MemberExpr,
11-
NameExpr,
1211
OverloadedFuncDef,
1312
RefExpr,
1413
StrExpr,
1514
SymbolTableNode,
1615
TypeInfo,
1716
Var,
1817
)
19-
from mypy.plugin import AttributeContext, ClassDefContext, DynamicClassDefContext, MethodContext
18+
from mypy.plugin import AttributeContext, DynamicClassDefContext, SemanticAnalyzerPluginInterface
2019
from mypy.types import AnyType, CallableType, Instance, ProperType
2120
from mypy.types import Type as MypyType
2221
from mypy.types import TypeOfAny
2322
from typing_extensions import Final
2423

25-
from mypy_django_plugin import errorcodes
2624
from mypy_django_plugin.lib import fullnames, helpers
2725

2826
MANAGER_METHODS_RETURNING_QUERYSET: Final = frozenset(
@@ -182,81 +180,110 @@ def create_new_manager_class_from_from_queryset_method(ctx: DynamicClassDefConte
182180
"""
183181
semanal_api = helpers.get_semanal_api(ctx)
184182

183+
# TODO: Emit an error when called in a class scope
184+
if semanal_api.is_class_scope():
185+
return
186+
185187
# Don't redeclare the manager class if we've already defined it.
186188
manager_node = semanal_api.lookup_current_scope(ctx.name)
187189
if manager_node and isinstance(manager_node.node, TypeInfo):
188190
# This is just a deferral run where our work is already finished
189191
return
190192

191-
callee = ctx.call.callee
192-
assert isinstance(callee, MemberExpr)
193-
assert isinstance(callee.expr, RefExpr)
194-
195-
base_manager_info = callee.expr.node
196-
if base_manager_info is None:
197-
if not semanal_api.final_iteration:
198-
semanal_api.defer()
193+
new_manager_info = create_manager_info_from_from_queryset_call(ctx.api, ctx.call, ctx.name)
194+
if new_manager_info is None:
195+
if not ctx.api.final_iteration:
196+
ctx.api.defer()
199197
return
200198

201-
assert isinstance(base_manager_info, TypeInfo)
199+
# So that the plugin will reparameterize the manager when it is constructed inside of a Model definition
200+
helpers.add_new_manager_base(semanal_api, new_manager_info.fullname)
202201

203-
passed_queryset = ctx.call.args[0]
204-
assert isinstance(passed_queryset, NameExpr)
205202

206-
derived_queryset_fullname = passed_queryset.fullname
207-
if derived_queryset_fullname is None:
208-
# In some cases, due to the way the semantic analyzer works, only passed_queryset.name is available.
209-
# But it should be analyzed again, so this isn't a problem.
210-
return
203+
def create_manager_info_from_from_queryset_call(
204+
api: SemanticAnalyzerPluginInterface, call_expr: CallExpr, name: Optional[str] = None
205+
) -> Optional[TypeInfo]:
206+
"""
207+
Extract manager and queryset TypeInfo from a from_queryset call.
208+
"""
211209

212-
base_manager_instance = fill_typevars(base_manager_info)
213-
assert isinstance(base_manager_instance, Instance)
214-
new_manager_info = semanal_api.basic_new_typeinfo(
215-
ctx.name, basetype_or_fallback=base_manager_instance, line=ctx.call.line
216-
)
210+
if (
211+
# Check that this is a from_queryset call on a manager subclass
212+
not isinstance(call_expr.callee, MemberExpr)
213+
or not isinstance(call_expr.callee.expr, RefExpr)
214+
or not isinstance(call_expr.callee.expr.node, TypeInfo)
215+
or not call_expr.callee.expr.node.has_base(fullnames.BASE_MANAGER_CLASS_FULLNAME)
216+
or not call_expr.callee.name == "from_queryset"
217+
# Check that the call has one or two arguments and that the first is a
218+
# QuerySet subclass
219+
or not 1 <= len(call_expr.args) <= 2
220+
or not isinstance(call_expr.args[0], RefExpr)
221+
or not isinstance(call_expr.args[0].node, TypeInfo)
222+
or not call_expr.args[0].node.has_base(fullnames.QUERYSET_CLASS_FULLNAME)
223+
):
224+
return None
217225

218-
sym = semanal_api.lookup_fully_qualified_or_none(derived_queryset_fullname)
219-
assert sym is not None
220-
if sym.node is None:
221-
if not semanal_api.final_iteration:
222-
semanal_api.defer()
223-
else:
224-
# inherit from Any to prevent false-positives, if queryset class cannot be resolved
225-
new_manager_info.fallback_to_any = True
226-
return
226+
base_manager_info, queryset_info = call_expr.callee.expr.node, call_expr.args[0].node
227+
if queryset_info.fullname is None:
228+
# In some cases, due to the way the semantic analyzer works, only
229+
# passed_queryset.name is available. But it should be analyzed again,
230+
# so this isn't a problem.
231+
return None
227232

228-
derived_queryset_info = sym.node
229-
assert isinstance(derived_queryset_info, TypeInfo)
230-
231-
new_manager_info.line = ctx.call.line
232-
new_manager_info.type_vars = base_manager_info.type_vars
233-
new_manager_info.defn.type_vars = base_manager_info.defn.type_vars
234-
new_manager_info.defn.line = ctx.call.line
235-
new_manager_info.metaclass_type = new_manager_info.calculate_metaclass_type()
236-
# Stash the queryset fullname which was passed to .from_queryset
237-
# So that our 'resolve_manager_method' attribute hook can fetch the method from that QuerySet class
238-
new_manager_info.metadata["django"] = {"from_queryset_manager": derived_queryset_fullname}
239-
240-
if len(ctx.call.args) > 1:
241-
expr = ctx.call.args[1]
242-
assert isinstance(expr, StrExpr)
243-
custom_manager_generated_name = expr.value
233+
if len(call_expr.args) == 2 and isinstance(call_expr.args[1], StrExpr):
234+
manager_name = call_expr.args[1].value
244235
else:
245-
custom_manager_generated_name = base_manager_info.name + "From" + derived_queryset_info.name
236+
manager_name = f"{base_manager_info.name}From{queryset_info.name}"
246237

247-
custom_manager_generated_fullname = ".".join(["django.db.models.manager", custom_manager_generated_name])
238+
new_manager_info = create_manager_class(api, base_manager_info, name or manager_name, call_expr.line)
239+
240+
popuplate_manager_from_queryset(new_manager_info, queryset_info)
241+
242+
manager_fullname = ".".join(["django.db.models.manager", manager_name])
243+
244+
base_manager_info = new_manager_info.mro[1]
248245
base_manager_info.metadata.setdefault("from_queryset_managers", {})
249-
base_manager_info.metadata["from_queryset_managers"][custom_manager_generated_fullname] = new_manager_info.fullname
246+
base_manager_info.metadata["from_queryset_managers"][manager_fullname] = new_manager_info.fullname
247+
248+
# Add the new manager to the current module
249+
module = api.modules[api.cur_mod_id]
250+
module.names[name or manager_name] = SymbolTableNode(
251+
GDEF, new_manager_info, plugin_generated=True, no_serialize=False
252+
)
253+
254+
return new_manager_info
250255

251-
# So that the plugin will reparameterize the manager when it is constructed inside of a Model definition
252-
helpers.add_new_manager_base(semanal_api, new_manager_info.fullname)
253256

254-
class_def_context = ClassDefContext(cls=new_manager_info.defn, reason=ctx.call, api=semanal_api)
255-
self_type = fill_typevars(new_manager_info)
256-
assert isinstance(self_type, Instance)
257+
def create_manager_class(
258+
api: SemanticAnalyzerPluginInterface, base_manager_info: TypeInfo, name: str, line: int
259+
) -> TypeInfo:
260+
261+
base_manager_instance = fill_typevars(base_manager_info)
262+
assert isinstance(base_manager_instance, Instance)
263+
264+
manager_info = helpers.create_type_info(name, api.cur_mod_id, bases=[base_manager_instance])
265+
manager_info.line = line
266+
manager_info.type_vars = base_manager_info.type_vars
267+
manager_info.defn.type_vars = base_manager_info.defn.type_vars
268+
manager_info.defn.line = line
269+
manager_info.metaclass_type = manager_info.calculate_metaclass_type()
270+
271+
return manager_info
272+
273+
274+
def popuplate_manager_from_queryset(manager_info: TypeInfo, queryset_info: TypeInfo) -> None:
275+
"""
276+
Add methods from the QuerySet class to the manager.
277+
"""
278+
279+
# Stash the queryset fullname which was passed to .from_queryset So that
280+
# our 'resolve_manager_method' attribute hook can fetch the method from
281+
# that QuerySet class
282+
django_metadata = helpers.get_django_metadata(manager_info)
283+
django_metadata["from_queryset_manager"] = queryset_info.fullname
257284

258285
# We collect and mark up all methods before django.db.models.query.QuerySet as class members
259-
for class_mro_info in derived_queryset_info.mro:
286+
for class_mro_info in queryset_info.mro:
260287
if class_mro_info.fullname == fullnames.QUERYSET_CLASS_FULLNAME:
261288
break
262289
for name, sym in class_mro_info.names.items():
@@ -270,39 +297,19 @@ def create_new_manager_class_from_from_queryset_method(ctx: DynamicClassDefConte
270297
# queryset_method: Any = ...
271298
#
272299
helpers.add_new_sym_for_info(
273-
new_manager_info,
300+
manager_info,
274301
name=name,
275302
sym_type=AnyType(TypeOfAny.special_form),
276303
)
277304

278-
# For methods on BaseManager that return a queryset we need to update the
279-
# return type to be the actual queryset subclass used. This is done by
280-
# adding the methods as attributes with type Any to the manager class,
281-
# similar to how custom queryset methods are handled above. The actual type
282-
# of these methods are resolved in resolve_manager_method.
283-
for name in MANAGER_METHODS_RETURNING_QUERYSET:
305+
# For methods on BaseManager that return a queryset we need to update
306+
# the return type to be the actual queryset subclass used. This is done
307+
# by adding the methods as attributes with type Any to the manager
308+
# class. The actual type of these methods are resolved in
309+
# resolve_manager_method.
310+
for method_name in MANAGER_METHODS_RETURNING_QUERYSET:
284311
helpers.add_new_sym_for_info(
285-
new_manager_info,
286-
name=name,
312+
manager_info,
313+
name=method_name,
287314
sym_type=AnyType(TypeOfAny.special_form),
288315
)
289-
290-
# Insert the new manager (dynamic) class
291-
assert semanal_api.add_symbol_table_node(ctx.name, SymbolTableNode(GDEF, new_manager_info, plugin_generated=True))
292-
293-
294-
def fail_if_manager_type_created_in_model_body(ctx: MethodContext) -> MypyType:
295-
"""
296-
Method hook that checks if method `<Manager>.from_queryset` is called inside a model class body.
297-
298-
Doing so won't, for instance, trigger the dynamic class hook(`create_new_manager_class_from_from_queryset_method`)
299-
for managers.
300-
"""
301-
api = helpers.get_typechecker_api(ctx)
302-
outer_model_info = api.scope.active_class()
303-
if not outer_model_info or not outer_model_info.has_base(fullnames.MODEL_CLASS_FULLNAME):
304-
# Not inside a model class definition
305-
return ctx.default_return_type
306-
307-
api.fail("`.from_queryset` called from inside model class body", ctx.context, code=errorcodes.MANAGER_UNTYPED)
308-
return ctx.default_return_type

0 commit comments

Comments
 (0)