diff --git a/django-stubs/db/models/fields/related_descriptors.pyi b/django-stubs/db/models/fields/related_descriptors.pyi index 644d15c0f..e02b2d67d 100644 --- a/django-stubs/db/models/fields/related_descriptors.pyi +++ b/django-stubs/db/models/fields/related_descriptors.pyi @@ -1,5 +1,5 @@ from collections.abc import Callable -from typing import Any, Generic, TypeVar +from typing import Any, Generic, TypeVar, overload from django.core.exceptions import ObjectDoesNotExist from django.db.models.base import Model @@ -12,6 +12,8 @@ from django.db.models.query_utils import DeferredAttribute _T = TypeVar("_T") _F = TypeVar("_F", bound=Field) +_From = TypeVar("_From", bound=Model) +_To = TypeVar("_To", bound=Model) class ForeignKeyDeferredAttribute(DeferredAttribute): field: RelatedField @@ -36,19 +38,31 @@ class ForwardManyToOneDescriptor(Generic[_F]): class ForwardOneToOneDescriptor(ForwardManyToOneDescriptor[_F]): def get_object(self, instance: Model) -> Model: ... -class ReverseOneToOneDescriptor: +class ReverseOneToOneDescriptor(Generic[_From, _To]): + """ + In the example:: + + class Restaurant(Model): + place = OneToOneField(Place, related_name='restaurant') + + ``Place.restaurant`` is a ``ReverseOneToOneDescriptor`` instance. + """ + related: OneToOneRel def __init__(self, related: OneToOneRel) -> None: ... @property def RelatedObjectDoesNotExist(self) -> type[ObjectDoesNotExist]: ... - def is_cached(self, instance: Model) -> bool: ... - def get_queryset(self, **hints: Any) -> QuerySet: ... + def is_cached(self, instance: _From) -> bool: ... + def get_queryset(self, **hints: Any) -> QuerySet[_To]: ... def get_prefetch_queryset( - self, instances: list[Model], queryset: QuerySet | None = ... - ) -> tuple[QuerySet, Callable, Callable, bool, str, bool]: ... - def __get__(self, instance: Model | None, cls: type[Model] | None = ...) -> Model | ReverseOneToOneDescriptor: ... - def __set__(self, instance: Model, value: Model | None) -> None: ... - def __reduce__(self) -> tuple[Callable, tuple[type[Model], str]]: ... + self, instances: list[_From], queryset: QuerySet[_To] | None = ... + ) -> tuple[QuerySet[_To], Callable[..., Any], Callable[..., Any], bool, str, bool]: ... + @overload + def __get__(self, instance: None, cls: Any = ...) -> ReverseOneToOneDescriptor[_From, _To]: ... + @overload + def __get__(self, instance: _From, cls: Any = ...) -> _To: ... + def __set__(self, instance: _From, value: _To | None) -> None: ... + def __reduce__(self) -> tuple[Callable[..., Any], tuple[type[_To], str]]: ... class ReverseManyToOneDescriptor: rel: ManyToOneRel diff --git a/mypy_django_plugin/lib/fullnames.py b/mypy_django_plugin/lib/fullnames.py index 33a2e1181..5e9c8d4e9 100644 --- a/mypy_django_plugin/lib/fullnames.py +++ b/mypy_django_plugin/lib/fullnames.py @@ -32,6 +32,7 @@ BASE_MANAGER_CLASS_FULLNAME, } +REVERSE_ONE_TO_ONE_DESCRIPTOR = "django.db.models.fields.related_descriptors.ReverseOneToOneDescriptor" RELATED_FIELDS_CLASSES = frozenset( ( FOREIGN_OBJECT_FULLNAME, diff --git a/mypy_django_plugin/transformers/models.py b/mypy_django_plugin/transformers/models.py index 0f86c3bd4..909f5dc50 100644 --- a/mypy_django_plugin/transformers/models.py +++ b/mypy_django_plugin/transformers/models.py @@ -440,7 +440,7 @@ def run_with_model_cls(self, model_cls: Type[Model]) -> None: self.add_new_node_to_model_class("_default_manager", default_manager, is_classvar=True) -class AddRelatedManagers(ModelClassInitializer): +class AddReverseLookups(ModelClassInitializer): def get_reverse_manager_info(self, model_info: TypeInfo, derived_from: str) -> Optional[TypeInfo]: manager_fullname = helpers.get_django_metadata(model_info).get("reverse_managers", {}).get(derived_from) if not manager_fullname: @@ -455,6 +455,9 @@ def set_reverse_manager_info(self, model_info: TypeInfo, derived_from: str, full helpers.get_django_metadata(model_info).setdefault("reverse_managers", {})[derived_from] = fullname def run_with_model_cls(self, model_cls: Type[Model]) -> None: + reverse_one_to_one_descriptor = self.lookup_typeinfo_or_incomplete_defn_error( + fullnames.REVERSE_ONE_TO_ONE_DESCRIPTOR + ) # add related managers for relation in self.django_context.get_model_relations(model_cls): attname = relation.get_accessor_name() @@ -474,7 +477,13 @@ def run_with_model_cls(self, model_cls: Type[Model]) -> None: continue if isinstance(relation, OneToOneRel): - self.add_new_node_to_model_class(attname, Instance(related_model_info, [])) + self.add_new_node_to_model_class( + attname, + Instance( + reverse_one_to_one_descriptor, + [Instance(self.model_classdef.info, []), Instance(related_model_info, [])], + ), + ) continue if isinstance(relation, ForeignObjectRel): @@ -732,7 +741,7 @@ def process_model_class(ctx: ClassDefContext, django_context: DjangoContext) -> AddRelatedModelsId, AddManagers, AddDefaultManagerAttribute, - AddRelatedManagers, + AddReverseLookups, AddExtraFieldMethods, AddMetaOptionsAttribute, MetaclassAdjustments, diff --git a/tests/typecheck/fields/test_related.yml b/tests/typecheck/fields/test_related.yml index f20f5043a..fcee5a6d1 100644 --- a/tests/typecheck/fields/test_related.yml +++ b/tests/typecheck/fields/test_related.yml @@ -1011,3 +1011,49 @@ class Book(PrintedGood): name = models.CharField() + +- case: test_reverse_one_to_one_descriptor + main: | + from myapp.models import MyModel, Other + reveal_type(MyModel.first.RelatedObjectDoesNotExist) + reveal_type(Other.mymodel) + reveal_type(Other.mymodel.get_queryset()) + reveal_type(Other.mymodel.RelatedObjectDoesNotExist) + reveal_type(Other.has_explicit_name.RelatedObjectDoesNotExist) + try: + other = Other.objects.get() + reveal_type(other.mymodel) + except Other.mymodel.RelatedObjectDoesNotExist: + ... + else: + other.mymodel = MyModel() + other.mymodel = Other() + other.mymodel = None + other.has_explicit_name = MyModel() + other.has_explicit_name = Other() + other.has_explicit_name = None + out: | + main:2: note: Revealed type is "Type[django.core.exceptions.ObjectDoesNotExist]" + main:3: note: Revealed type is "django.db.models.fields.related_descriptors.ReverseOneToOneDescriptor[myapp.models.Other, myapp.models.MyModel]" + main:4: note: Revealed type is "django.db.models.query._QuerySet[myapp.models.MyModel, myapp.models.MyModel]" + main:5: note: Revealed type is "Type[django.core.exceptions.ObjectDoesNotExist]" + main:6: note: Revealed type is "Type[django.core.exceptions.ObjectDoesNotExist]" + main:9: note: Revealed type is "myapp.models.MyModel" + main:14: error: Incompatible types in assignment (expression has type "Other", variable has type "Optional[MyModel]") + main:17: error: Incompatible types in assignment (expression has type "Other", variable has type "Optional[MyModel]") + installed_apps: + - myapp + files: + - path: myapp/__init__.py + - path: myapp/models/__init__.py + content: | + from django.db import models + + class Other(models.Model): + ... + + class MyModel(models.Model): + first = models.OneToOneField(Other, on_delete=models.CASCADE) + second = models.OneToOneField( + Other, on_delete=models.CASCADE, related_name="has_explicit_name" + ) diff --git a/tests/typecheck/models/test_create.yml b/tests/typecheck/models/test_create.yml index e82ff7be4..203f23bcc 100644 --- a/tests/typecheck/models/test_create.yml +++ b/tests/typecheck/models/test_create.yml @@ -39,6 +39,8 @@ main: | from myapp.models import Child4 Child4.objects.create(name1='n1', name2='n2', value=1, value4=4) + out: | + myapp/models:9: error: Definition of "child1" in base class "Parent1" is incompatible with definition in base class "Parent2" installed_apps: - myapp files: