mirror of
https://github.com/davidhalter/django-stubs.git
synced 2025-12-12 15:01:55 +08:00
* Add test case reproducing Sequence name not defined issue * Resolve all manager methods as attribute This changes to logic for resolving methods from the base QuerySet class on managers from copying the methods to use the attribute approach that's already used for methods from custom querysets. This resolves the phantom type errors that stem from the copying. * Disable cache in test case Make sure the test will fail regardless of which mypy.ini file is being using. Co-authored-by: Petter Friberg <petter@5monkeys.se> * Update comments related to copying methods * Use a predefined list of manager methods to update The list of manager methods that returns a queryset, and thus need to have it's return type changed, is small and well defined. Using a predefined list of methods rather than trying to detect these at runtime makes the code much more readable and probably faster as well. Also add `extra()` to the methods tested in from_queryset_includes_methods_returning_queryset, and sort the methods alphabetically. * Revert changes in .github/workflows/tests.yml With cache_disable: true on the test case this is no longer needed to reproduce the bug. * Remove unsued imports and change type of constant - Remove unused imports left behind - Change MANAGER_METHODS_RETURNING_QUERYSET to Final[FrozenSet[str]] * Import Final from typing_extensions Was added in 3.8, we still support 3.7 * Sort imports properly * Remove explicit typing of final frozenset Co-authored-by: Nikita Sobolev <mail@sobolevn.me> * Add comment for test case * Fix typo * Rename variable Co-authored-by: Petter Friberg <petter@5monkeys.se> Co-authored-by: Nikita Sobolev <mail@sobolevn.me>
309 lines
12 KiB
Python
309 lines
12 KiB
Python
from typing import Optional, Union
|
|
|
|
from mypy.checker import TypeChecker, fill_typevars
|
|
from mypy.nodes import (
|
|
GDEF,
|
|
CallExpr,
|
|
Decorator,
|
|
FuncBase,
|
|
FuncDef,
|
|
MemberExpr,
|
|
NameExpr,
|
|
OverloadedFuncDef,
|
|
RefExpr,
|
|
StrExpr,
|
|
SymbolTableNode,
|
|
TypeInfo,
|
|
Var,
|
|
)
|
|
from mypy.plugin import AttributeContext, ClassDefContext, DynamicClassDefContext, MethodContext
|
|
from mypy.types import AnyType, CallableType, Instance, ProperType
|
|
from mypy.types import Type as MypyType
|
|
from mypy.types import TypeOfAny
|
|
from typing_extensions import Final
|
|
|
|
from mypy_django_plugin import errorcodes
|
|
from mypy_django_plugin.lib import fullnames, helpers
|
|
|
|
MANAGER_METHODS_RETURNING_QUERYSET: Final = frozenset(
|
|
(
|
|
"alias",
|
|
"all",
|
|
"annotate",
|
|
"complex_filter",
|
|
"defer",
|
|
"difference",
|
|
"distinct",
|
|
"exclude",
|
|
"extra",
|
|
"filter",
|
|
"intersection",
|
|
"none",
|
|
"only",
|
|
"order_by",
|
|
"prefetch_related",
|
|
"reverse",
|
|
"select_for_update",
|
|
"select_related",
|
|
"union",
|
|
"using",
|
|
)
|
|
)
|
|
|
|
|
|
def get_method_type_from_dynamic_manager(
|
|
api: TypeChecker, method_name: str, manager_instance: Instance
|
|
) -> Optional[ProperType]:
|
|
"""
|
|
Attempt to resolve a method on a manager that was built from '.from_queryset'
|
|
"""
|
|
|
|
manager_type_info = manager_instance.type
|
|
|
|
if (
|
|
"django" not in manager_type_info.metadata
|
|
or "from_queryset_manager" not in manager_type_info.metadata["django"]
|
|
):
|
|
# Manager isn't dynamically added
|
|
return None
|
|
|
|
queryset_fullname = manager_type_info.metadata["django"]["from_queryset_manager"]
|
|
assert isinstance(queryset_fullname, str)
|
|
queryset_info = helpers.lookup_fully_qualified_typeinfo(api, queryset_fullname)
|
|
assert queryset_info is not None
|
|
|
|
def get_funcdef_type(definition: Union[FuncBase, Decorator, None]) -> Optional[ProperType]:
|
|
# TODO: Handle @overload?
|
|
if isinstance(definition, FuncBase) and not isinstance(definition, OverloadedFuncDef):
|
|
return definition.type
|
|
elif isinstance(definition, Decorator):
|
|
return definition.func.type
|
|
return None
|
|
|
|
method_type = get_funcdef_type(queryset_info.get_method(method_name))
|
|
if method_type is None:
|
|
return None
|
|
|
|
assert isinstance(method_type, CallableType)
|
|
|
|
variables = method_type.variables
|
|
ret_type = method_type.ret_type
|
|
|
|
# For methods on the manager that return a queryset we need to override the
|
|
# return type to be the actual queryset class, not the base QuerySet that's
|
|
# used by the typing stubs.
|
|
if method_name in MANAGER_METHODS_RETURNING_QUERYSET:
|
|
ret_type = Instance(queryset_info, manager_instance.args)
|
|
variables = []
|
|
|
|
# Drop any 'self' argument as our manager is already initialized
|
|
return method_type.copy_modified(
|
|
arg_types=method_type.arg_types[1:],
|
|
arg_kinds=method_type.arg_kinds[1:],
|
|
arg_names=method_type.arg_names[1:],
|
|
variables=variables,
|
|
ret_type=ret_type,
|
|
)
|
|
|
|
|
|
def get_method_type_from_reverse_manager(
|
|
api: TypeChecker, method_name: str, manager_type_info: TypeInfo
|
|
) -> Optional[ProperType]:
|
|
"""
|
|
Attempts to resolve a reverse manager's method via the '_default_manager' manager on the related model
|
|
From Django docs:
|
|
"By default the RelatedManager used for reverse relations is a subclass of the default manager for that model."
|
|
Ref: https://docs.djangoproject.com/en/dev/topics/db/queries/#using-a-custom-reverse-manager
|
|
"""
|
|
is_reverse_manager = (
|
|
"django" in manager_type_info.metadata and "related_manager_to_model" in manager_type_info.metadata["django"]
|
|
)
|
|
if not is_reverse_manager:
|
|
return None
|
|
|
|
related_model_fullname = manager_type_info.metadata["django"]["related_manager_to_model"]
|
|
assert isinstance(related_model_fullname, str)
|
|
model_info = helpers.lookup_fully_qualified_typeinfo(api, related_model_fullname)
|
|
if model_info is None:
|
|
return None
|
|
|
|
# We should _always_ have a '_default_manager' on a model
|
|
assert "_default_manager" in model_info.names
|
|
assert isinstance(model_info.names["_default_manager"].node, Var)
|
|
manager_instance = model_info.names["_default_manager"].node.type
|
|
return (
|
|
get_method_type_from_dynamic_manager(api, method_name, manager_instance)
|
|
# TODO: Can we assert on None and Instance?
|
|
if manager_instance is not None and isinstance(manager_instance, Instance)
|
|
else None
|
|
)
|
|
|
|
|
|
def resolve_manager_method_from_instance(instance: Instance, method_name: str, ctx: AttributeContext) -> MypyType:
|
|
|
|
api = helpers.get_typechecker_api(ctx)
|
|
method_type = get_method_type_from_dynamic_manager(
|
|
api, method_name, instance
|
|
) or get_method_type_from_reverse_manager(api, method_name, instance.type)
|
|
|
|
return method_type if method_type is not None else ctx.default_attr_type
|
|
|
|
|
|
def resolve_manager_method(ctx: AttributeContext) -> MypyType:
|
|
"""
|
|
A 'get_attribute_hook' that is intended to be invoked whenever the TypeChecker encounters
|
|
an attribute on a class that has 'django.db.models.BaseManager' as a base.
|
|
"""
|
|
# Skip (method) type that is currently something other than Any
|
|
if not isinstance(ctx.default_attr_type, AnyType):
|
|
return ctx.default_attr_type
|
|
|
|
# (Current state is:) We wouldn't end up here when looking up a method from a custom _manager_.
|
|
# That's why we only attempt to lookup the method for either a dynamically added or reverse manager.
|
|
if isinstance(ctx.context, MemberExpr):
|
|
method_name = ctx.context.name
|
|
elif isinstance(ctx.context, CallExpr) and isinstance(ctx.context.callee, MemberExpr):
|
|
method_name = ctx.context.callee.name
|
|
else:
|
|
ctx.api.fail("Unable to resolve return type of queryset/manager method", ctx.context)
|
|
return AnyType(TypeOfAny.from_error)
|
|
|
|
if isinstance(ctx.type, Instance):
|
|
return resolve_manager_method_from_instance(instance=ctx.type, method_name=method_name, ctx=ctx)
|
|
else:
|
|
ctx.api.fail(f'Unable to resolve return type of queryset/manager method "{method_name}"', ctx.context)
|
|
return AnyType(TypeOfAny.from_error)
|
|
|
|
|
|
def create_new_manager_class_from_from_queryset_method(ctx: DynamicClassDefContext) -> None:
|
|
"""
|
|
Insert a new manager class node for a: '<Name> = <Manager>.from_queryset(<QuerySet>)'.
|
|
When the assignment expression lives at module level.
|
|
"""
|
|
semanal_api = helpers.get_semanal_api(ctx)
|
|
|
|
# Don't redeclare the manager class if we've already defined it.
|
|
manager_node = semanal_api.lookup_current_scope(ctx.name)
|
|
if manager_node and isinstance(manager_node.node, TypeInfo):
|
|
# This is just a deferral run where our work is already finished
|
|
return
|
|
|
|
callee = ctx.call.callee
|
|
assert isinstance(callee, MemberExpr)
|
|
assert isinstance(callee.expr, RefExpr)
|
|
|
|
base_manager_info = callee.expr.node
|
|
if base_manager_info is None:
|
|
if not semanal_api.final_iteration:
|
|
semanal_api.defer()
|
|
return
|
|
|
|
assert isinstance(base_manager_info, TypeInfo)
|
|
|
|
passed_queryset = ctx.call.args[0]
|
|
assert isinstance(passed_queryset, NameExpr)
|
|
|
|
derived_queryset_fullname = passed_queryset.fullname
|
|
if derived_queryset_fullname is None:
|
|
# In some cases, due to the way the semantic analyzer works, only passed_queryset.name is available.
|
|
# But it should be analyzed again, so this isn't a problem.
|
|
return
|
|
|
|
base_manager_instance = fill_typevars(base_manager_info)
|
|
assert isinstance(base_manager_instance, Instance)
|
|
new_manager_info = semanal_api.basic_new_typeinfo(
|
|
ctx.name, basetype_or_fallback=base_manager_instance, line=ctx.call.line
|
|
)
|
|
|
|
sym = semanal_api.lookup_fully_qualified_or_none(derived_queryset_fullname)
|
|
assert sym is not None
|
|
if sym.node is None:
|
|
if not semanal_api.final_iteration:
|
|
semanal_api.defer()
|
|
else:
|
|
# inherit from Any to prevent false-positives, if queryset class cannot be resolved
|
|
new_manager_info.fallback_to_any = True
|
|
return
|
|
|
|
derived_queryset_info = sym.node
|
|
assert isinstance(derived_queryset_info, TypeInfo)
|
|
|
|
new_manager_info.line = ctx.call.line
|
|
new_manager_info.type_vars = base_manager_info.type_vars
|
|
new_manager_info.defn.type_vars = base_manager_info.defn.type_vars
|
|
new_manager_info.defn.line = ctx.call.line
|
|
new_manager_info.metaclass_type = new_manager_info.calculate_metaclass_type()
|
|
# Stash the queryset fullname which was passed to .from_queryset
|
|
# So that our 'resolve_manager_method' attribute hook can fetch the method from that QuerySet class
|
|
new_manager_info.metadata["django"] = {"from_queryset_manager": derived_queryset_fullname}
|
|
|
|
if len(ctx.call.args) > 1:
|
|
expr = ctx.call.args[1]
|
|
assert isinstance(expr, StrExpr)
|
|
custom_manager_generated_name = expr.value
|
|
else:
|
|
custom_manager_generated_name = base_manager_info.name + "From" + derived_queryset_info.name
|
|
|
|
custom_manager_generated_fullname = ".".join(["django.db.models.manager", custom_manager_generated_name])
|
|
base_manager_info.metadata.setdefault("from_queryset_managers", {})
|
|
base_manager_info.metadata["from_queryset_managers"][custom_manager_generated_fullname] = new_manager_info.fullname
|
|
|
|
# So that the plugin will reparameterize the manager when it is constructed inside of a Model definition
|
|
helpers.add_new_manager_base(semanal_api, new_manager_info.fullname)
|
|
|
|
class_def_context = ClassDefContext(cls=new_manager_info.defn, reason=ctx.call, api=semanal_api)
|
|
self_type = fill_typevars(new_manager_info)
|
|
assert isinstance(self_type, Instance)
|
|
|
|
# We collect and mark up all methods before django.db.models.query.QuerySet as class members
|
|
for class_mro_info in derived_queryset_info.mro:
|
|
if class_mro_info.fullname == fullnames.QUERYSET_CLASS_FULLNAME:
|
|
break
|
|
for name, sym in class_mro_info.names.items():
|
|
if not isinstance(sym.node, (FuncDef, Decorator)):
|
|
continue
|
|
# Insert the queryset method name as a class member. Note that the type of
|
|
# the method is set as Any. Figuring out the type is the job of the
|
|
# 'resolve_manager_method' attribute hook, which comes later.
|
|
#
|
|
# class BaseManagerFromMyQuerySet(BaseManager):
|
|
# queryset_method: Any = ...
|
|
#
|
|
helpers.add_new_sym_for_info(
|
|
new_manager_info,
|
|
name=name,
|
|
sym_type=AnyType(TypeOfAny.special_form),
|
|
)
|
|
|
|
# For methods on BaseManager that return a queryset we need to update the
|
|
# return type to be the actual queryset subclass used. This is done by
|
|
# adding the methods as attributes with type Any to the manager class,
|
|
# similar to how custom queryset methods are handled above. The actual type
|
|
# of these methods are resolved in resolve_manager_method.
|
|
for name in MANAGER_METHODS_RETURNING_QUERYSET:
|
|
helpers.add_new_sym_for_info(
|
|
new_manager_info,
|
|
name=name,
|
|
sym_type=AnyType(TypeOfAny.special_form),
|
|
)
|
|
|
|
# Insert the new manager (dynamic) class
|
|
assert semanal_api.add_symbol_table_node(ctx.name, SymbolTableNode(GDEF, new_manager_info, plugin_generated=True))
|
|
|
|
|
|
def fail_if_manager_type_created_in_model_body(ctx: MethodContext) -> MypyType:
|
|
"""
|
|
Method hook that checks if method `<Manager>.from_queryset` is called inside a model class body.
|
|
|
|
Doing so won't, for instance, trigger the dynamic class hook(`create_new_manager_class_from_from_queryset_method`)
|
|
for managers.
|
|
"""
|
|
api = helpers.get_typechecker_api(ctx)
|
|
outer_model_info = api.scope.active_class()
|
|
if not outer_model_info or not outer_model_info.has_base(fullnames.MODEL_CLASS_FULLNAME):
|
|
# Not inside a model class definition
|
|
return ctx.default_return_type
|
|
|
|
api.fail("`.from_queryset` called from inside model class body", ctx.context, code=errorcodes.MANAGER_UNTYPED)
|
|
return ctx.default_return_type
|