Skip to content

Commit c797714

Browse files
committed
Improve hints for ManyToManyDescriptor and start using it
1 parent 41b86f7 commit c797714

File tree

11 files changed

+158
-50
lines changed

11 files changed

+158
-50
lines changed

django-stubs/db/models/fields/related.pyi

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ class ManyToManyField(RelatedField[Any, Any], Generic[_To, _M]):
253253
) -> None: ...
254254
# class access
255255
@overload
256-
def __get__(self, instance: None, owner: Any) -> ManyToManyDescriptor[_M]: ...
256+
def __get__(self, instance: None, owner: Any) -> ManyToManyDescriptor[_To, _M]: ...
257257
# Model instance access
258258
@overload
259259
def __get__(self, instance: Model, owner: Any) -> ManyRelatedManager[_To]: ...

django-stubs/db/models/fields/related_descriptors.pyi

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ from typing_extensions import Self
1414
_M = TypeVar("_M", bound=Model)
1515
_F = TypeVar("_F", bound=Field)
1616
_From = TypeVar("_From", bound=Model)
17+
_Through = TypeVar("_Through", bound=Model)
1718
_To = TypeVar("_To", bound=Model)
1819

1920
class ForeignKeyDeferredAttribute(DeferredAttribute):
@@ -83,14 +84,14 @@ class ReverseManyToOneDescriptor:
8384
@overload
8485
def __get__(self, instance: None, cls: Any = ...) -> Self: ...
8586
@overload
86-
def __get__(self, instance: Model, cls: Any = ...) -> type[RelatedManager[Any]]: ...
87+
def __get__(self, instance: Model, cls: Any = ...) -> RelatedManager[Any]: ...
8788
def __set__(self, instance: Any, value: Any) -> NoReturn: ...
8889

8990
def create_reverse_many_to_one_manager(
9091
superclass: type[BaseManager[_M]], rel: ManyToOneRel
9192
) -> type[RelatedManager[_M]]: ...
9293

93-
class ManyToManyDescriptor(ReverseManyToOneDescriptor, Generic[_M]):
94+
class ManyToManyDescriptor(ReverseManyToOneDescriptor, Generic[_To, _Through]):
9495
"""
9596
In the example::
9697
@@ -103,13 +104,17 @@ class ManyToManyDescriptor(ReverseManyToOneDescriptor, Generic[_M]):
103104

104105
# 'field' here is 'rel.field'
105106
rel: ManyToManyRel # type: ignore[assignment]
106-
field: ManyToManyField[Any, _M] # type: ignore[assignment]
107+
field: ManyToManyField[_To, _Through] # type: ignore[assignment]
107108
reverse: bool
108109
def __init__(self, rel: ManyToManyRel, reverse: bool = ...) -> None: ...
109110
@property
110-
def through(self) -> type[_M]: ...
111+
def through(self) -> type[_Through]: ...
111112
@property
112-
def related_manager_cls(self) -> type[ManyRelatedManager[Any]]: ... # type: ignore[override]
113+
def related_manager_cls(self) -> type[ManyRelatedManager[_To]]: ... # type: ignore[override]
114+
@overload # type: ignore[override]
115+
def __get__(self, instance: None, cls: Any = ...) -> Self: ...
116+
@overload
117+
def __get__(self, instance: Model, cls: Any = ...) -> ManyRelatedManager[_To]: ...
113118

114119
class ManyRelatedManager(BaseManager[_M], Generic[_M]):
115120
related_val: tuple[int, ...]
@@ -121,7 +126,7 @@ class ManyRelatedManager(BaseManager[_M], Generic[_M]):
121126
async def aset(self, objs: QuerySet[_M] | Iterable[_M | int], *, bulk: bool = ..., clear: bool = ...) -> None: ...
122127
def clear(self) -> None: ...
123128
async def aclear(self) -> None: ...
124-
def __call__(self, *, manager: str) -> ManyRelatedManager[_M]: ...
129+
def __call__(self, *, manager: str) -> Self: ...
125130

126131
def create_forward_many_to_many_manager(
127132
superclass: type[BaseManager[_M]], rel: ManyToManyRel, reverse: bool

mypy_django_plugin/lib/fullnames.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
}
3535

3636
REVERSE_ONE_TO_ONE_DESCRIPTOR = "django.db.models.fields.related_descriptors.ReverseOneToOneDescriptor"
37+
MANY_TO_MANY_DESCRIPTOR = "django.db.models.fields.related_descriptors.ManyToManyDescriptor"
38+
MANY_RELATED_MANAGER = "django.db.models.fields.related_descriptors.ManyRelatedManager"
3739
RELATED_FIELDS_CLASSES = frozenset(
3840
(
3941
FOREIGN_OBJECT_FULLNAME,

mypy_django_plugin/lib/helpers.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,20 @@ def get_django_metadata_bases(
6767
return get_django_metadata(model_info).setdefault(key, cast(Dict[str, int], {}))
6868

6969

70+
def get_reverse_manager_info(
71+
api: Union[TypeChecker, SemanticAnalyzer], model_info: TypeInfo, derived_from: str
72+
) -> Optional[TypeInfo]:
73+
manager_fullname = get_django_metadata(model_info).get("reverse_managers", {}).get(derived_from)
74+
if not manager_fullname:
75+
return None
76+
77+
return lookup_fully_qualified_typeinfo(api, manager_fullname)
78+
79+
80+
def set_reverse_manager_info(model_info: TypeInfo, derived_from: str, fullname: str) -> None:
81+
get_django_metadata(model_info).setdefault("reverse_managers", {})[derived_from] = fullname
82+
83+
7084
class IncompleteDefnException(Exception):
7185
pass
7286

mypy_django_plugin/main.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from mypy_django_plugin.django.context import DjangoContext
2424
from mypy_django_plugin.exceptions import UnregisteredModelError
2525
from mypy_django_plugin.lib import fullnames, helpers
26-
from mypy_django_plugin.transformers import fields, forms, init_create, meta, querysets, request, settings
26+
from mypy_django_plugin.transformers import fields, forms, init_create, manytomany, meta, querysets, request, settings
2727
from mypy_django_plugin.transformers.functional import resolve_str_promise_attribute
2828
from mypy_django_plugin.transformers.managers import (
2929
create_new_manager_class_from_as_manager_method,
@@ -187,6 +187,11 @@ def get_method_hook(self, fullname: str) -> Optional[Callable[[MethodContext], M
187187
info = self._get_typeinfo_or_none(class_fullname)
188188
if info and info.has_base(fullnames.FORM_MIXIN_CLASS_FULLNAME):
189189
return forms.extract_proper_type_for_get_form
190+
elif method_name == "__get__" and class_fullname in {
191+
fullnames.MANYTOMANY_FIELD_FULLNAME,
192+
fullnames.MANY_TO_MANY_DESCRIPTOR,
193+
}:
194+
return manytomany.refine_many_to_many_related_manager
190195

191196
manager_classes = self._get_current_manager_bases()
192197

mypy_django_plugin/transformers/manytomany.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from mypy.checker import TypeChecker
44
from mypy.nodes import AssignmentStmt, Expression, MemberExpr, NameExpr, StrExpr, TypeInfo
5-
from mypy.plugin import FunctionContext
5+
from mypy.plugin import FunctionContext, MethodContext
66
from mypy.semanal import SemanticAnalyzer
77
from mypy.types import Instance, ProperType, UninhabitedType
88
from mypy.types import Type as MypyType
@@ -151,3 +151,52 @@ def get_model_from_expression(
151151
if model_info is not None:
152152
return Instance(model_info, [])
153153
return None
154+
155+
156+
def refine_many_to_many_related_manager(ctx: MethodContext) -> MypyType:
157+
"""
158+
Updates the 'ManyRelatedManager' returned by e.g. 'ManyToManyDescriptor' to be a subclass
159+
of 'ManyRelatedManager' and the related model's default manager.
160+
"""
161+
if (
162+
not isinstance(ctx.default_return_type, Instance)
163+
# Only change a return type being 'ManyRelatedManager'
164+
or ctx.default_return_type.type.fullname != fullnames.MANY_RELATED_MANAGER
165+
):
166+
return ctx.default_return_type
167+
168+
# This is a call to '__get__' overload with a model instance of 'ManyToManyDescriptor'.
169+
# Returning a 'ManyRelatedManager'. Which we want to, just like Django, build from the
170+
# default manager of the related model.
171+
many_related_manager = ctx.default_return_type
172+
# Require first type argument of 'ManyRelatedManager' to be a model
173+
if (
174+
not many_related_manager.args
175+
or not isinstance(many_related_manager.args[0], Instance)
176+
or many_related_manager.args[0].type.metaclass_type is None
177+
or many_related_manager.args[0].type.metaclass_type.type.fullname != fullnames.MODEL_METACLASS_FULLNAME
178+
):
179+
return ctx.default_return_type
180+
181+
checker = helpers.get_typechecker_api(ctx)
182+
related_model_instance = many_related_manager.args[0].copy_modified()
183+
related_manager_info = helpers.get_reverse_manager_info(
184+
checker, related_model_instance.type, derived_from="_default_manager"
185+
)
186+
if related_manager_info is None:
187+
default_manager_node = related_model_instance.type.names.get("_default_manager")
188+
if default_manager_node is None or not isinstance(default_manager_node.type, Instance):
189+
return ctx.default_return_type
190+
191+
related_manager_info = helpers.add_new_class_for_module(
192+
module=checker.modules[related_model_instance.type.module_name],
193+
name=f"{related_model_instance.type.name}_ManyRelatedManager",
194+
bases=[many_related_manager, default_manager_node.type],
195+
)
196+
related_manager_info.metadata["django"] = {"related_manager_to_model": related_model_instance.type.fullname}
197+
helpers.set_reverse_manager_info(
198+
related_model_instance.type,
199+
derived_from="_default_manager",
200+
fullname=related_manager_info.fullname,
201+
)
202+
return Instance(related_manager_info, [])

mypy_django_plugin/transformers/models.py

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from django.db.models import Manager, Model
55
from django.db.models.fields import DateField, DateTimeField, Field
6-
from django.db.models.fields.reverse_related import ForeignObjectRel, OneToOneRel
6+
from django.db.models.fields.reverse_related import ManyToManyRel, OneToOneRel
77
from mypy.checker import TypeChecker
88
from mypy.nodes import (
99
ARG_STAR2,
@@ -448,23 +448,15 @@ def run_with_model_cls(self, model_cls: Type[Model]) -> None:
448448

449449

450450
class AddReverseLookups(ModelClassInitializer):
451-
def get_reverse_manager_info(self, model_info: TypeInfo, derived_from: str) -> Optional[TypeInfo]:
452-
manager_fullname = helpers.get_django_metadata(model_info).get("reverse_managers", {}).get(derived_from)
453-
if not manager_fullname:
454-
return None
455-
456-
symbol = self.api.lookup_fully_qualified_or_none(manager_fullname)
457-
if symbol is None or not isinstance(symbol.node, TypeInfo):
458-
return None
459-
return symbol.node
451+
@cached_property
452+
def reverse_one_to_one_descriptor(self) -> TypeInfo:
453+
return self.lookup_typeinfo_or_incomplete_defn_error(fullnames.REVERSE_ONE_TO_ONE_DESCRIPTOR)
460454

461-
def set_reverse_manager_info(self, model_info: TypeInfo, derived_from: str, fullname: str) -> None:
462-
helpers.get_django_metadata(model_info).setdefault("reverse_managers", {})[derived_from] = fullname
455+
@cached_property
456+
def many_to_many_descriptor(self) -> TypeInfo:
457+
return self.lookup_typeinfo_or_incomplete_defn_error(fullnames.MANY_TO_MANY_DESCRIPTOR)
463458

464459
def run_with_model_cls(self, model_cls: Type[Model]) -> None:
465-
reverse_one_to_one_descriptor = self.lookup_typeinfo_or_incomplete_defn_error(
466-
fullnames.REVERSE_ONE_TO_ONE_DESCRIPTOR
467-
)
468460
# add related managers
469461
for relation in self.django_context.get_model_relations(model_cls):
470462
attname = relation.get_accessor_name()
@@ -487,13 +479,25 @@ def run_with_model_cls(self, model_cls: Type[Model]) -> None:
487479
self.add_new_node_to_model_class(
488480
attname,
489481
Instance(
490-
reverse_one_to_one_descriptor,
482+
self.reverse_one_to_one_descriptor,
491483
[Instance(self.model_classdef.info, []), Instance(related_model_info, [])],
492484
),
493485
)
494486
continue
495-
496-
if isinstance(relation, ForeignObjectRel):
487+
elif isinstance(relation, ManyToManyRel):
488+
# TODO: 'relation' should be based on `TypeInfo` instead of Django runtime..
489+
to_fullname = helpers.get_class_fullname(relation.remote_field.model)
490+
to_model_info = self.lookup_typeinfo_or_incomplete_defn_error(to_fullname)
491+
assert relation.through is not None
492+
through_fullname = helpers.get_class_fullname(relation.through)
493+
through_model_info = self.lookup_typeinfo_or_incomplete_defn_error(through_fullname)
494+
self.add_new_node_to_model_class(
495+
attname,
496+
Instance(
497+
self.many_to_many_descriptor, [Instance(to_model_info, []), Instance(through_model_info, [])]
498+
),
499+
)
500+
else:
497501
related_manager_info = None
498502
try:
499503
related_manager_info = self.lookup_typeinfo_or_incomplete_defn_error(
@@ -534,8 +538,8 @@ def run_with_model_cls(self, model_cls: Type[Model]) -> None:
534538

535539
# Check if the related model has a related manager subclassed from the default manager
536540
# TODO: Support other reverse managers than `_default_manager`
537-
default_reverse_manager_info = self.get_reverse_manager_info(
538-
model_info=related_model_info, derived_from="_default_manager"
541+
default_reverse_manager_info = helpers.get_reverse_manager_info(
542+
self.api, model_info=related_model_info, derived_from="_default_manager"
539543
)
540544
if default_reverse_manager_info:
541545
self.add_new_node_to_model_class(attname, Instance(default_reverse_manager_info, []))
@@ -564,7 +568,7 @@ def run_with_model_cls(self, model_cls: Type[Model]) -> None:
564568
new_related_manager_info.metadata["django"] = {"related_manager_to_model": related_model_info.fullname}
565569
# Stash the new reverse manager type fullname on the related model, so we don't duplicate
566570
# or have to create it again for other reverse relations
567-
self.set_reverse_manager_info(
571+
helpers.set_reverse_manager_info(
568572
related_model_info,
569573
derived_from="_default_manager",
570574
fullname=new_related_manager_info.fullname,

mypy_django_plugin/transformers/orm_lookups.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,7 @@ def typecheck_queryset_filter(ctx: MethodContext, django_context: DjangoContext)
1515
lookup_kwargs = ctx.arg_names[1] if len(ctx.arg_names) >= 2 else []
1616
provided_lookup_types = ctx.arg_types[1] if len(ctx.arg_types) >= 2 else []
1717

18-
assert isinstance(ctx.type, Instance)
19-
20-
if not ctx.type.args or not isinstance(ctx.type.args[0], Instance):
18+
if not isinstance(ctx.type, Instance) or not ctx.type.args or not isinstance(ctx.type.args[0], Instance):
2119
return ctx.default_return_type
2220

2321
model_cls_fullname = ctx.type.args[0].type.fullname

scripts/stubtest/allowlist_todo.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,6 @@ django.contrib.auth.models.GroupManager.__slotnames__
170170
django.contrib.auth.models.Permission.codename
171171
django.contrib.auth.models.Permission.content_type
172172
django.contrib.auth.models.Permission.content_type_id
173-
django.contrib.auth.models.Permission.group_set
174173
django.contrib.auth.models.Permission.id
175174
django.contrib.auth.models.Permission.name
176175
django.contrib.auth.models.Permission.user_set

0 commit comments

Comments
 (0)