Skip to content

Commit e6598f8

Browse files
authored
Improve hints for ReverseOneToOneDescriptor and start using it (#1733)
1 parent 076e637 commit e6598f8

File tree

5 files changed

+84
-12
lines changed

5 files changed

+84
-12
lines changed

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

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from collections.abc import Callable
2-
from typing import Any, Generic, TypeVar
2+
from typing import Any, Generic, TypeVar, overload
33

44
from django.core.exceptions import ObjectDoesNotExist
55
from django.db.models.base import Model
@@ -12,6 +12,8 @@ from django.db.models.query_utils import DeferredAttribute
1212

1313
_T = TypeVar("_T")
1414
_F = TypeVar("_F", bound=Field)
15+
_From = TypeVar("_From", bound=Model)
16+
_To = TypeVar("_To", bound=Model)
1517

1618
class ForeignKeyDeferredAttribute(DeferredAttribute):
1719
field: RelatedField
@@ -36,19 +38,31 @@ class ForwardManyToOneDescriptor(Generic[_F]):
3638
class ForwardOneToOneDescriptor(ForwardManyToOneDescriptor[_F]):
3739
def get_object(self, instance: Model) -> Model: ...
3840

39-
class ReverseOneToOneDescriptor:
41+
class ReverseOneToOneDescriptor(Generic[_From, _To]):
42+
"""
43+
In the example::
44+
45+
class Restaurant(Model):
46+
place = OneToOneField(Place, related_name='restaurant')
47+
48+
``Place.restaurant`` is a ``ReverseOneToOneDescriptor`` instance.
49+
"""
50+
4051
related: OneToOneRel
4152
def __init__(self, related: OneToOneRel) -> None: ...
4253
@property
4354
def RelatedObjectDoesNotExist(self) -> type[ObjectDoesNotExist]: ...
44-
def is_cached(self, instance: Model) -> bool: ...
45-
def get_queryset(self, **hints: Any) -> QuerySet: ...
55+
def is_cached(self, instance: _From) -> bool: ...
56+
def get_queryset(self, **hints: Any) -> QuerySet[_To]: ...
4657
def get_prefetch_queryset(
47-
self, instances: list[Model], queryset: QuerySet | None = ...
48-
) -> tuple[QuerySet, Callable, Callable, bool, str, bool]: ...
49-
def __get__(self, instance: Model | None, cls: type[Model] | None = ...) -> Model | ReverseOneToOneDescriptor: ...
50-
def __set__(self, instance: Model, value: Model | None) -> None: ...
51-
def __reduce__(self) -> tuple[Callable, tuple[type[Model], str]]: ...
58+
self, instances: list[_From], queryset: QuerySet[_To] | None = ...
59+
) -> tuple[QuerySet[_To], Callable[..., Any], Callable[..., Any], bool, str, bool]: ...
60+
@overload
61+
def __get__(self, instance: None, cls: Any = ...) -> ReverseOneToOneDescriptor[_From, _To]: ...
62+
@overload
63+
def __get__(self, instance: _From, cls: Any = ...) -> _To: ...
64+
def __set__(self, instance: _From, value: _To | None) -> None: ...
65+
def __reduce__(self) -> tuple[Callable[..., Any], tuple[type[_To], str]]: ...
5266

5367
class ReverseManyToOneDescriptor:
5468
rel: ManyToOneRel

mypy_django_plugin/lib/fullnames.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
BASE_MANAGER_CLASS_FULLNAME,
3333
}
3434

35+
REVERSE_ONE_TO_ONE_DESCRIPTOR = "django.db.models.fields.related_descriptors.ReverseOneToOneDescriptor"
3536
RELATED_FIELDS_CLASSES = frozenset(
3637
(
3738
FOREIGN_OBJECT_FULLNAME,

mypy_django_plugin/transformers/models.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,7 @@ def run_with_model_cls(self, model_cls: Type[Model]) -> None:
440440
self.add_new_node_to_model_class("_default_manager", default_manager, is_classvar=True)
441441

442442

443-
class AddRelatedManagers(ModelClassInitializer):
443+
class AddReverseLookups(ModelClassInitializer):
444444
def get_reverse_manager_info(self, model_info: TypeInfo, derived_from: str) -> Optional[TypeInfo]:
445445
manager_fullname = helpers.get_django_metadata(model_info).get("reverse_managers", {}).get(derived_from)
446446
if not manager_fullname:
@@ -455,6 +455,9 @@ def set_reverse_manager_info(self, model_info: TypeInfo, derived_from: str, full
455455
helpers.get_django_metadata(model_info).setdefault("reverse_managers", {})[derived_from] = fullname
456456

457457
def run_with_model_cls(self, model_cls: Type[Model]) -> None:
458+
reverse_one_to_one_descriptor = self.lookup_typeinfo_or_incomplete_defn_error(
459+
fullnames.REVERSE_ONE_TO_ONE_DESCRIPTOR
460+
)
458461
# add related managers
459462
for relation in self.django_context.get_model_relations(model_cls):
460463
attname = relation.get_accessor_name()
@@ -474,7 +477,13 @@ def run_with_model_cls(self, model_cls: Type[Model]) -> None:
474477
continue
475478

476479
if isinstance(relation, OneToOneRel):
477-
self.add_new_node_to_model_class(attname, Instance(related_model_info, []))
480+
self.add_new_node_to_model_class(
481+
attname,
482+
Instance(
483+
reverse_one_to_one_descriptor,
484+
[Instance(self.model_classdef.info, []), Instance(related_model_info, [])],
485+
),
486+
)
478487
continue
479488

480489
if isinstance(relation, ForeignObjectRel):
@@ -732,7 +741,7 @@ def process_model_class(ctx: ClassDefContext, django_context: DjangoContext) ->
732741
AddRelatedModelsId,
733742
AddManagers,
734743
AddDefaultManagerAttribute,
735-
AddRelatedManagers,
744+
AddReverseLookups,
736745
AddExtraFieldMethods,
737746
AddMetaOptionsAttribute,
738747
MetaclassAdjustments,

tests/typecheck/fields/test_related.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,3 +1011,49 @@
10111011
10121012
class Book(PrintedGood):
10131013
name = models.CharField()
1014+
1015+
- case: test_reverse_one_to_one_descriptor
1016+
main: |
1017+
from myapp.models import MyModel, Other
1018+
reveal_type(MyModel.first.RelatedObjectDoesNotExist)
1019+
reveal_type(Other.mymodel)
1020+
reveal_type(Other.mymodel.get_queryset())
1021+
reveal_type(Other.mymodel.RelatedObjectDoesNotExist)
1022+
reveal_type(Other.has_explicit_name.RelatedObjectDoesNotExist)
1023+
try:
1024+
other = Other.objects.get()
1025+
reveal_type(other.mymodel)
1026+
except Other.mymodel.RelatedObjectDoesNotExist:
1027+
...
1028+
else:
1029+
other.mymodel = MyModel()
1030+
other.mymodel = Other()
1031+
other.mymodel = None
1032+
other.has_explicit_name = MyModel()
1033+
other.has_explicit_name = Other()
1034+
other.has_explicit_name = None
1035+
out: |
1036+
main:2: note: Revealed type is "Type[django.core.exceptions.ObjectDoesNotExist]"
1037+
main:3: note: Revealed type is "django.db.models.fields.related_descriptors.ReverseOneToOneDescriptor[myapp.models.Other, myapp.models.MyModel]"
1038+
main:4: note: Revealed type is "django.db.models.query._QuerySet[myapp.models.MyModel, myapp.models.MyModel]"
1039+
main:5: note: Revealed type is "Type[django.core.exceptions.ObjectDoesNotExist]"
1040+
main:6: note: Revealed type is "Type[django.core.exceptions.ObjectDoesNotExist]"
1041+
main:9: note: Revealed type is "myapp.models.MyModel"
1042+
main:14: error: Incompatible types in assignment (expression has type "Other", variable has type "Optional[MyModel]")
1043+
main:17: error: Incompatible types in assignment (expression has type "Other", variable has type "Optional[MyModel]")
1044+
installed_apps:
1045+
- myapp
1046+
files:
1047+
- path: myapp/__init__.py
1048+
- path: myapp/models/__init__.py
1049+
content: |
1050+
from django.db import models
1051+
1052+
class Other(models.Model):
1053+
...
1054+
1055+
class MyModel(models.Model):
1056+
first = models.OneToOneField(Other, on_delete=models.CASCADE)
1057+
second = models.OneToOneField(
1058+
Other, on_delete=models.CASCADE, related_name="has_explicit_name"
1059+
)

tests/typecheck/models/test_create.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
main: |
4040
from myapp.models import Child4
4141
Child4.objects.create(name1='n1', name2='n2', value=1, value4=4)
42+
out: |
43+
myapp/models:9: error: Definition of "child1" in base class "Parent1" is incompatible with definition in base class "Parent2"
4244
installed_apps:
4345
- myapp
4446
files:

0 commit comments

Comments
 (0)