Skip to content

Commit 54d5835

Browse files
authored
Implement support for <QuerySet>.as_manager() (#1025)
* Implement support for `<QuerySet>.as_manager()` * fixup! Implement support for `<QuerySet>.as_manager()` * fixup! fixup! Implement support for `<QuerySet>.as_manager()`
1 parent 1f2e406 commit 54d5835

File tree

8 files changed

+583
-83
lines changed

8 files changed

+583
-83
lines changed

README.md

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,7 @@ And then use `AuthenticatedHttpRequest` instead of the standard `HttpRequest` fo
145145

146146
### My QuerySet methods are returning Any rather than my Model
147147

148-
`QuerySet.as_manager()` is not currently supported.
149-
150-
If you are using `MyQuerySet.as_manager()`, then your `Manager`/`QuerySet` methods will all not be linked to your model.
148+
If you are using `MyQuerySet.as_manager()`:
151149

152150
Example:
153151

@@ -163,12 +161,12 @@ class MyModel(models.Model):
163161
objects = MyModelQuerySet.as_manager()
164162

165163

166-
def use_my_model():
167-
foo = MyModel.objects.get(id=1) # This is `Any` but it should be `MyModel`
168-
return foo.xyz # No error, but there should be
164+
def use_my_model() -> int:
165+
foo = MyModel.objects.get(id=1) # Should now be `MyModel`
166+
return foo.xyz # Gives an error
169167
```
170168

171-
There is a workaround: use `Manager.from_queryset` instead.
169+
Or if you're using `Manager.from_queryset`:
172170

173171
Example:
174172

@@ -188,9 +186,9 @@ class MyModel(models.Model):
188186
objects = MyModelManager()
189187

190188

191-
def use_my_model():
192-
foo = MyModel.objects.get(id=1)
193-
return foo.xyz # Gives an error
189+
def use_my_model() -> int:
190+
foo = MyModel.objects.get(id=1) # Should now be `MyModel`
191+
return foo.xyz # Gives an error
194192
```
195193

196194
### How do I annotate cases where I called QuerySet.annotate?

mypy_django_plugin/lib/helpers.py

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -387,29 +387,27 @@ def bind_or_analyze_type(t: MypyType, api: SemanticAnalyzer, module_name: Option
387387

388388

389389
def copy_method_to_another_class(
390-
ctx: ClassDefContext,
390+
api: SemanticAnalyzer,
391+
cls: ClassDef,
391392
self_type: Instance,
392393
new_method_name: str,
393394
method_node: FuncDef,
394395
return_type: Optional[MypyType] = None,
395396
original_module_name: Optional[str] = None,
396397
) -> bool:
397-
semanal_api = get_semanal_api(ctx)
398398
if method_node.type is None:
399399
arguments, return_type = build_unannotated_method_args(method_node)
400-
add_method_to_class(
401-
semanal_api, ctx.cls, new_method_name, args=arguments, return_type=return_type, self_type=self_type
402-
)
400+
add_method_to_class(api, cls, new_method_name, args=arguments, return_type=return_type, self_type=self_type)
403401
return True
404402

405403
method_type = method_node.type
406404
if not isinstance(method_type, CallableType):
407-
if not semanal_api.final_iteration:
408-
semanal_api.defer()
405+
if not api.final_iteration:
406+
api.defer()
409407
return False
410408

411409
if return_type is None:
412-
return_type = bind_or_analyze_type(method_type.ret_type, semanal_api, original_module_name)
410+
return_type = bind_or_analyze_type(method_type.ret_type, api, original_module_name)
413411
if return_type is None:
414412
return False
415413

@@ -422,7 +420,7 @@ def copy_method_to_another_class(
422420
zip(method_type.arg_types[1:], method_type.arg_kinds[1:], method_type.arg_names[1:]),
423421
start=1,
424422
):
425-
bound_arg_type = bind_or_analyze_type(arg_type, semanal_api, original_module_name)
423+
bound_arg_type = bind_or_analyze_type(arg_type, api, original_module_name)
426424
if bound_arg_type is None:
427425
return False
428426
if arg_name is None and hasattr(method_node, "arguments"):
@@ -438,9 +436,7 @@ def copy_method_to_another_class(
438436
)
439437
)
440438

441-
add_method_to_class(
442-
semanal_api, ctx.cls, new_method_name, args=arguments, return_type=return_type, self_type=self_type
443-
)
439+
add_method_to_class(api, cls, new_method_name, args=arguments, return_type=return_type, self_type=self_type)
444440

445441
return True
446442

mypy_django_plugin/main.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from mypy_django_plugin.transformers import fields, forms, init_create, meta, querysets, request, settings
2525
from mypy_django_plugin.transformers.functional import resolve_str_promise_attribute
2626
from mypy_django_plugin.transformers.managers import (
27+
create_new_manager_class_from_as_manager_method,
2728
create_new_manager_class_from_from_queryset_method,
2829
resolve_manager_method,
2930
)
@@ -301,11 +302,15 @@ def get_type_analyze_hook(self, fullname: str) -> Optional[Callable[[AnalyzeType
301302

302303
def get_dynamic_class_hook(self, fullname: str) -> Optional[Callable[[DynamicClassDefContext], None]]:
303304
# Create a new manager class definition when a manager's '.from_queryset' classmethod is called
304-
if fullname.endswith("from_queryset"):
305-
class_name, _, _ = fullname.rpartition(".")
305+
class_name, _, method_name = fullname.rpartition(".")
306+
if method_name == "from_queryset":
306307
info = self._get_typeinfo_or_none(class_name)
307308
if info and info.has_base(fullnames.BASE_MANAGER_CLASS_FULLNAME):
308309
return create_new_manager_class_from_from_queryset_method
310+
elif method_name == "as_manager":
311+
info = self._get_typeinfo_or_none(class_name)
312+
if info and info.has_base(fullnames.QUERYSET_CLASS_FULLNAME):
313+
return create_new_manager_class_from_as_manager_method
309314
return None
310315

311316

mypy_django_plugin/transformers/managers.py

Lines changed: 162 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
TypeInfo,
1616
Var,
1717
)
18-
from mypy.plugin import AttributeContext, DynamicClassDefContext, SemanticAnalyzerPluginInterface
18+
from mypy.plugin import AttributeContext, DynamicClassDefContext
19+
from mypy.semanal import SemanticAnalyzer
1920
from mypy.semanal_shared import has_placeholder
2021
from mypy.types import AnyType, CallableType, Instance, ProperType
2122
from mypy.types import Type as MypyType
@@ -150,7 +151,6 @@ def get_method_type_from_reverse_manager(
150151

151152

152153
def resolve_manager_method_from_instance(instance: Instance, method_name: str, ctx: AttributeContext) -> MypyType:
153-
154154
api = helpers.get_typechecker_api(ctx)
155155
method_type = get_method_type_from_dynamic_manager(
156156
api, method_name, instance
@@ -164,9 +164,11 @@ def resolve_manager_method(ctx: AttributeContext) -> MypyType:
164164
A 'get_attribute_hook' that is intended to be invoked whenever the TypeChecker encounters
165165
an attribute on a class that has 'django.db.models.BaseManager' as a base.
166166
"""
167-
# Skip (method) type that is currently something other than Any
167+
# Skip (method) type that is currently something other than Any of type `implementation_artifact`
168168
if not isinstance(ctx.default_attr_type, AnyType):
169169
return ctx.default_attr_type
170+
elif ctx.default_attr_type.type_of_any != TypeOfAny.implementation_artifact:
171+
return ctx.default_attr_type
170172

171173
# (Current state is:) We wouldn't end up here when looking up a method from a custom _manager_.
172174
# That's why we only attempt to lookup the method for either a dynamically added or reverse manager.
@@ -197,12 +199,12 @@ def create_new_manager_class_from_from_queryset_method(ctx: DynamicClassDefConte
197199
return
198200

199201
# Don't redeclare the manager class if we've already defined it.
200-
manager_node = semanal_api.lookup_current_scope(ctx.name)
201-
if manager_node and isinstance(manager_node.node, TypeInfo):
202+
manager_sym = semanal_api.lookup_current_scope(ctx.name)
203+
if manager_sym and isinstance(manager_sym.node, TypeInfo):
202204
# This is just a deferral run where our work is already finished
203205
return
204206

205-
new_manager_info = create_manager_info_from_from_queryset_call(ctx.api, ctx.call, ctx.name)
207+
new_manager_info = create_manager_info_from_from_queryset_call(semanal_api, ctx.call, ctx.name)
206208
if new_manager_info is None:
207209
if not ctx.api.final_iteration:
208210
ctx.api.defer()
@@ -212,8 +214,17 @@ def create_new_manager_class_from_from_queryset_method(ctx: DynamicClassDefConte
212214
helpers.add_new_manager_base(semanal_api, new_manager_info.fullname)
213215

214216

217+
def register_dynamically_created_manager(fullname: str, manager_name: str, manager_base: TypeInfo) -> None:
218+
manager_base.metadata.setdefault("from_queryset_managers", {})
219+
# The `__module__` value of the manager type created by Django's
220+
# `.from_queryset` is `django.db.models.manager`. But we put new type(s) in the
221+
# module currently being processed, so we'll map those together through metadata.
222+
runtime_fullname = ".".join(["django.db.models.manager", manager_name])
223+
manager_base.metadata["from_queryset_managers"][runtime_fullname] = fullname
224+
225+
215226
def create_manager_info_from_from_queryset_call(
216-
api: SemanticAnalyzerPluginInterface, call_expr: CallExpr, name: Optional[str] = None
227+
api: SemanticAnalyzer, call_expr: CallExpr, name: Optional[str] = None
217228
) -> Optional[TypeInfo]:
218229
"""
219230
Extract manager and queryset TypeInfo from a from_queryset call.
@@ -247,30 +258,48 @@ def create_manager_info_from_from_queryset_call(
247258
else:
248259
manager_name = f"{base_manager_info.name}From{queryset_info.name}"
249260

250-
try:
251-
new_manager_info = create_manager_class(api, base_manager_info, name or manager_name, call_expr.line)
252-
except helpers.IncompleteDefnException:
253-
return None
254-
255-
popuplate_manager_from_queryset(new_manager_info, queryset_info)
256-
257-
manager_fullname = ".".join(["django.db.models.manager", manager_name])
258-
259-
base_manager_info = new_manager_info.mro[1]
260-
base_manager_info.metadata.setdefault("from_queryset_managers", {})
261-
base_manager_info.metadata["from_queryset_managers"][manager_fullname] = new_manager_info.fullname
261+
# Always look in global scope, as that's where we'll declare dynamic manager classes
262+
manager_sym = api.globals.get(manager_name)
263+
if (
264+
manager_sym is not None
265+
and isinstance(manager_sym.node, TypeInfo)
266+
and manager_sym.node.has_base(base_manager_info.fullname)
267+
and manager_sym.node.metadata.get("django", {}).get("from_queryset_manager") == queryset_info.fullname
268+
):
269+
# Reuse an identical, already generated, manager
270+
new_manager_info = manager_sym.node
271+
else:
272+
# Create a new `TypeInfo` instance for the manager type
273+
try:
274+
new_manager_info = create_manager_class(
275+
api=api,
276+
base_manager_info=base_manager_info,
277+
name=manager_name,
278+
line=call_expr.line,
279+
with_unique_name=name is not None and name != manager_name,
280+
)
281+
except helpers.IncompleteDefnException:
282+
return None
283+
284+
populate_manager_from_queryset(new_manager_info, queryset_info)
285+
register_dynamically_created_manager(
286+
fullname=new_manager_info.fullname,
287+
manager_name=manager_name,
288+
manager_base=base_manager_info,
289+
)
262290

263291
# Add the new manager to the current module
264292
module = api.modules[api.cur_mod_id]
265-
module.names[name or manager_name] = SymbolTableNode(
266-
GDEF, new_manager_info, plugin_generated=True, no_serialize=False
267-
)
293+
if name is not None and name != new_manager_info.name:
294+
# Unless names are equal, there's 2 symbol names that needs the manager info
295+
module.names[name] = SymbolTableNode(GDEF, new_manager_info, plugin_generated=True)
268296

297+
module.names[new_manager_info.name] = SymbolTableNode(GDEF, new_manager_info, plugin_generated=True)
269298
return new_manager_info
270299

271300

272301
def create_manager_class(
273-
api: SemanticAnalyzerPluginInterface, base_manager_info: TypeInfo, name: str, line: int
302+
api: SemanticAnalyzer, base_manager_info: TypeInfo, name: str, line: int, with_unique_name: bool
274303
) -> TypeInfo:
275304

276305
base_manager_instance = fill_typevars(base_manager_info)
@@ -280,17 +309,24 @@ def create_manager_class(
280309
if any(has_placeholder(type_var) for type_var in base_manager_info.defn.type_vars):
281310
raise helpers.IncompleteDefnException
282311

283-
manager_info = helpers.create_type_info(name, api.cur_mod_id, bases=[base_manager_instance])
312+
if with_unique_name:
313+
manager_info = helpers.add_new_class_for_module(
314+
module=api.modules[api.cur_mod_id],
315+
name=name,
316+
bases=[base_manager_instance],
317+
)
318+
else:
319+
manager_info = helpers.create_type_info(name, api.cur_mod_id, bases=[base_manager_instance])
320+
284321
manager_info.line = line
285322
manager_info.type_vars = base_manager_info.type_vars
286323
manager_info.defn.type_vars = base_manager_info.defn.type_vars
287324
manager_info.defn.line = line
288-
manager_info.metaclass_type = manager_info.calculate_metaclass_type()
289325

290326
return manager_info
291327

292328

293-
def popuplate_manager_from_queryset(manager_info: TypeInfo, queryset_info: TypeInfo) -> None:
329+
def populate_manager_from_queryset(manager_info: TypeInfo, queryset_info: TypeInfo) -> None:
294330
"""
295331
Add methods from the QuerySet class to the manager.
296332
"""
@@ -318,7 +354,7 @@ def popuplate_manager_from_queryset(manager_info: TypeInfo, queryset_info: TypeI
318354
helpers.add_new_sym_for_info(
319355
manager_info,
320356
name=name,
321-
sym_type=AnyType(TypeOfAny.special_form),
357+
sym_type=AnyType(TypeOfAny.implementation_artifact),
322358
)
323359

324360
# For methods on BaseManager that return a queryset we need to update
@@ -330,5 +366,103 @@ def popuplate_manager_from_queryset(manager_info: TypeInfo, queryset_info: TypeI
330366
helpers.add_new_sym_for_info(
331367
manager_info,
332368
name=method_name,
333-
sym_type=AnyType(TypeOfAny.special_form),
369+
sym_type=AnyType(TypeOfAny.implementation_artifact),
334370
)
371+
372+
373+
def create_new_manager_class_from_as_manager_method(ctx: DynamicClassDefContext) -> None:
374+
"""
375+
Insert a new manager class node for a
376+
377+
```
378+
<manager name> = <QuerySet>.as_manager()
379+
```
380+
"""
381+
semanal_api = helpers.get_semanal_api(ctx)
382+
# Don't redeclare the manager class if we've already defined it.
383+
manager_node = semanal_api.lookup_current_scope(ctx.name)
384+
if manager_node and manager_node.type is not None:
385+
# This is just a deferral run where our work is already finished
386+
return
387+
388+
manager_sym = semanal_api.lookup_fully_qualified_or_none(fullnames.MANAGER_CLASS_FULLNAME)
389+
assert manager_sym is not None
390+
manager_base = manager_sym.node
391+
if manager_base is None:
392+
if not semanal_api.final_iteration:
393+
semanal_api.defer()
394+
return
395+
396+
assert isinstance(manager_base, TypeInfo)
397+
398+
callee = ctx.call.callee
399+
assert isinstance(callee, MemberExpr)
400+
assert isinstance(callee.expr, RefExpr)
401+
402+
queryset_info = callee.expr.node
403+
if queryset_info is None:
404+
if not semanal_api.final_iteration:
405+
semanal_api.defer()
406+
return
407+
408+
assert isinstance(queryset_info, TypeInfo)
409+
410+
manager_class_name = manager_base.name + "From" + queryset_info.name
411+
current_module = semanal_api.modules[semanal_api.cur_mod_id]
412+
existing_sym = current_module.names.get(manager_class_name)
413+
if (
414+
existing_sym is not None
415+
and isinstance(existing_sym.node, TypeInfo)
416+
and existing_sym.node.has_base(fullnames.MANAGER_CLASS_FULLNAME)
417+
and existing_sym.node.metadata.get("django", {}).get("from_queryset_manager") == queryset_info.fullname
418+
):
419+
# Reuse an identical, already generated, manager
420+
new_manager_info = existing_sym.node
421+
else:
422+
# Create a new `TypeInfo` instance for the manager type
423+
try:
424+
new_manager_info = create_manager_class(
425+
api=semanal_api,
426+
base_manager_info=manager_base,
427+
name=manager_class_name,
428+
line=ctx.call.line,
429+
with_unique_name=True,
430+
)
431+
except helpers.IncompleteDefnException:
432+
if not semanal_api.final_iteration:
433+
semanal_api.defer()
434+
return
435+
436+
populate_manager_from_queryset(new_manager_info, queryset_info)
437+
register_dynamically_created_manager(
438+
fullname=new_manager_info.fullname,
439+
manager_name=manager_class_name,
440+
manager_base=manager_base,
441+
)
442+
443+
# So that the plugin will reparameterize the manager when it is constructed inside of a Model definition
444+
helpers.add_new_manager_base(semanal_api, new_manager_info.fullname)
445+
446+
# Whenever `<QuerySet>.as_manager()` isn't called at class level, we want to ensure
447+
# that the variable is an instance of our generated manager. Instead of the return
448+
# value of `.as_manager()`. Though model argument is populated as `Any`.
449+
# `transformers.models.AddManagers` will populate a model's manager(s), when it
450+
# finds it on class level.
451+
var = Var(name=ctx.name, type=Instance(new_manager_info, [AnyType(TypeOfAny.from_omitted_generics)]))
452+
var.info = new_manager_info
453+
var._fullname = f"{current_module.fullname}.{ctx.name}"
454+
var.is_inferred = True
455+
# Note: Order of `add_symbol_table_node` calls matters. Depending on what level
456+
# we've found the `.as_manager()` call. Point here being that we want to replace the
457+
# `.as_manager` return value with our newly created manager.
458+
assert semanal_api.add_symbol_table_node(
459+
ctx.name, SymbolTableNode(semanal_api.current_symbol_kind(), var, plugin_generated=True)
460+
)
461+
# Add the new manager to the current module
462+
assert semanal_api.add_symbol_table_node(
463+
# We'll use `new_manager_info.name` instead of `manager_class_name` here
464+
# to handle possible name collisions, as it's unique.
465+
new_manager_info.name,
466+
# Note that the generated manager type is always inserted at module level
467+
SymbolTableNode(GDEF, new_manager_info, plugin_generated=True),
468+
)

0 commit comments

Comments
 (0)