Skip to content

Commit a0004b3

Browse files
committed
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
1 parent 2a6f464 commit a0004b3

File tree

5 files changed

+200
-132
lines changed

5 files changed

+200
-132
lines changed

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: 97 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
from typing import Optional, Union
1+
from typing import Optional, Tuple, Union
22

3+
from django.db.models import base, manager
34
from mypy.checker import TypeChecker, fill_typevars
45
from mypy.nodes import (
56
GDEF,
@@ -16,13 +17,12 @@
1617
TypeInfo,
1718
Var,
1819
)
19-
from mypy.plugin import AttributeContext, ClassDefContext, DynamicClassDefContext, MethodContext
20+
from mypy.plugin import AttributeContext, ClassDefContext, DynamicClassDefContext, SemanticAnalyzerPluginInterface
2021
from mypy.types import AnyType, CallableType, Instance, ProperType
2122
from mypy.types import Type as MypyType
2223
from mypy.types import TypeOfAny
2324
from typing_extensions import Final
2425

25-
from mypy_django_plugin import errorcodes
2626
from mypy_django_plugin.lib import fullnames, helpers
2727

2828
MANAGER_METHODS_RETURNING_QUERYSET: Final = frozenset(
@@ -182,81 +182,111 @@ def create_new_manager_class_from_from_queryset_method(ctx: DynamicClassDefConte
182182
"""
183183
semanal_api = helpers.get_semanal_api(ctx)
184184

185+
# TODO: Emit an error when called in a class scope
186+
if semanal_api.is_class_scope():
187+
return
188+
185189
# Don't redeclare the manager class if we've already defined it.
186190
manager_node = semanal_api.lookup_current_scope(ctx.name)
187191
if manager_node and isinstance(manager_node.node, TypeInfo):
188192
# This is just a deferral run where our work is already finished
189193
return
190194

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()
195+
new_manager_info, generated_name = create_manager_info_from_from_queryset_call(ctx.api, ctx.call, ctx.name)
196+
if new_manager_info is None:
197+
if not ctx.api.final_iteration:
198+
ctx.api.defer()
199199
return
200200

201-
assert isinstance(base_manager_info, TypeInfo)
201+
assert generated_name
202+
manager_fullname = ".".join(["django.db.models.manager", generated_name])
202203

203-
passed_queryset = ctx.call.args[0]
204-
assert isinstance(passed_queryset, NameExpr)
204+
base_manager_info = new_manager_info.mro[1]
205+
base_manager_info.metadata.setdefault("from_queryset_managers", {})
206+
base_manager_info.metadata["from_queryset_managers"][manager_fullname] = new_manager_info.fullname
205207

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
208+
# So that the plugin will reparameterize the manager when it is constructed inside of a Model definition
209+
helpers.add_new_manager_base(semanal_api, new_manager_info.fullname)
211210

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
211+
# Insert the new manager (dynamic) class
212+
assert semanal_api.add_symbol_table_node(
213+
ctx.name,
214+
SymbolTableNode(GDEF, new_manager_info, plugin_generated=True),
216215
)
217216

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
227217

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
218+
def create_manager_info_from_from_queryset_call(
219+
api: SemanticAnalyzerPluginInterface, call_expr: CallExpr, name: Optional[str] = None
220+
) -> Tuple[Optional[TypeInfo], Optional[str]]:
221+
"""
222+
Extract manager and queryset TypeInfo from a from_queryset call.
223+
"""
224+
225+
if (
226+
# Check that this is a from_queryset call on a manager subclass
227+
not isinstance(call_expr.callee, MemberExpr)
228+
or not isinstance(call_expr.callee.expr, RefExpr)
229+
or not isinstance(call_expr.callee.expr.node, TypeInfo)
230+
or not call_expr.callee.expr.node.has_base(fullnames.BASE_MANAGER_CLASS_FULLNAME)
231+
or not call_expr.callee.name == "from_queryset"
232+
# Check that the call has one or two arguments and that the first is a
233+
# QuerySet subclass
234+
or not 1 <= len(call_expr.args) <= 2
235+
or not isinstance(call_expr.args[0], RefExpr)
236+
or not isinstance(call_expr.args[0].node, TypeInfo)
237+
or not call_expr.args[0].node.has_base(fullnames.QUERYSET_CLASS_FULLNAME)
238+
):
239+
return None, None
240+
241+
base_manager_info, queryset_info = call_expr.callee.expr.node, call_expr.args[0].node
242+
if queryset_info.fullname is None:
243+
# In some cases, due to the way the semantic analyzer works, only
244+
# passed_queryset.name is available. But it should be analyzed again,
245+
# so this isn't a problem.
246+
return None, None
247+
248+
if len(call_expr.args) == 2 and isinstance(call_expr.args[1], StrExpr):
249+
manager_name = call_expr.args[1].value
244250
else:
245-
custom_manager_generated_name = base_manager_info.name + "From" + derived_queryset_info.name
251+
manager_name = f"{base_manager_info.name}From{queryset_info.name}"
246252

247-
custom_manager_generated_fullname = ".".join(["django.db.models.manager", custom_manager_generated_name])
248-
base_manager_info.metadata.setdefault("from_queryset_managers", {})
249-
base_manager_info.metadata["from_queryset_managers"][custom_manager_generated_fullname] = new_manager_info.fullname
253+
new_manager_info = create_manager_class(api, base_manager_info, name or manager_name, call_expr.line)
250254

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)
255+
popuplate_manager_from_queryset(new_manager_info, queryset_info)
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+
return new_manager_info, manager_name
258+
259+
260+
def create_manager_class(
261+
api: SemanticAnalyzerPluginInterface, base_manager_info: TypeInfo, name: str, line: int
262+
) -> TypeInfo:
263+
264+
base_manager_instance = fill_typevars(base_manager_info)
265+
assert isinstance(base_manager_instance, Instance)
266+
267+
manager_info = api.basic_new_typeinfo(name, basetype_or_fallback=base_manager_instance, line=line)
268+
manager_info.line = line
269+
manager_info.type_vars = base_manager_info.type_vars
270+
manager_info.defn.type_vars = base_manager_info.defn.type_vars
271+
manager_info.defn.line = line
272+
manager_info.metaclass_type = manager_info.calculate_metaclass_type()
273+
274+
return manager_info
275+
276+
277+
def popuplate_manager_from_queryset(manager_info: TypeInfo, queryset_info: TypeInfo) -> None:
278+
"""
279+
Add methods from the QuerySet class to the manager.
280+
"""
281+
282+
# Stash the queryset fullname which was passed to .from_queryset So that
283+
# our 'resolve_manager_method' attribute hook can fetch the method from
284+
# that QuerySet class
285+
django_metadata = helpers.get_django_metadata(manager_info)
286+
django_metadata["from_queryset_manager"] = queryset_info.fullname
257287

258288
# 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:
289+
for class_mro_info in queryset_info.mro:
260290
if class_mro_info.fullname == fullnames.QUERYSET_CLASS_FULLNAME:
261291
break
262292
for name, sym in class_mro_info.names.items():
@@ -270,39 +300,19 @@ def create_new_manager_class_from_from_queryset_method(ctx: DynamicClassDefConte
270300
# queryset_method: Any = ...
271301
#
272302
helpers.add_new_sym_for_info(
273-
new_manager_info,
303+
manager_info,
274304
name=name,
275305
sym_type=AnyType(TypeOfAny.special_form),
276306
)
277307

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:
308+
# For methods on BaseManager that return a queryset we need to update
309+
# the return type to be the actual queryset subclass used. This is done
310+
# by adding the methods as attributes with type Any to the manager
311+
# class. The actual type of these methods are resolved in
312+
# resolve_manager_method.
313+
for method_name in MANAGER_METHODS_RETURNING_QUERYSET:
284314
helpers.add_new_sym_for_info(
285-
new_manager_info,
286-
name=name,
315+
manager_info,
316+
name=method_name,
287317
sym_type=AnyType(TypeOfAny.special_form),
288318
)
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)