Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ option to get extra information about the error.
### I cannot use QuerySet or Manager with type annotations

You can get a `TypeError: 'type' object is not subscriptable`
when you will try to use `QuerySet[MyModel]`, `Manager[MyModel]` or some other Django-based Generic types.
when you will try to use `QuerySet[MyModel]`, `Manager[MyModel, MyQuerySet]` or some other Django-based Generic types.

This happens because these Django classes do not support [`__class_getitem__`](https://www.python.org/dev/peps/pep-0560/#class-getitem) magic method in runtime.

Expand Down Expand Up @@ -237,6 +237,20 @@ class MyManager(models.Manager["MyModel"]):
...
```

If your manager also declare a custom queryset via `get_queryset`, you might face a similar error

> Return type "MyQuerySet[MyModel, MyModel]" of "get_queryset" incompatible with return type "_QS" in supertype "django.db.models.manager.BaseManager"

To fix this issue, you have to properly pass custom `QuerySet` and `Manager` generic params:
```python
class MyQuerySet(models.QuerySet["MyModel"]):
...

class MyStaffManager(models.Manager["MyModel", MyQuerySet]):
def get_queryset(self) -> MyQuerySet:
return MyQuerySet(self.model, using=self._db)
```

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

Django-stubs provides a special type, `django_stubs_ext.WithAnnotations[Model, <Annotations>]`, which indicates that
Expand Down
4 changes: 3 additions & 1 deletion django-stubs/contrib/auth/base_user.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ from django.db import models
from django.db.models.base import Model
from django.db.models.expressions import Combinable
from django.db.models.fields import BooleanField
from django.db.models.query import QuerySet

_T = TypeVar("_T", bound=Model)
_QS = TypeVar("_QS", bound=QuerySet[Any], covariant=True, default=QuerySet[_T])

class BaseUserManager(models.Manager[_T]):
class BaseUserManager(models.Manager[_T, _QS]):
@classmethod
def normalize_email(cls, email: str | None) -> str: ...
def get_by_natural_key(self, username: str | None) -> _T: ...
Expand Down
4 changes: 2 additions & 2 deletions django-stubs/db/models/base.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ class ModelState:

class ModelBase(type):
@property
def _default_manager(cls: type[_Self]) -> Manager[_Self]: ... # type: ignore[misc]
def _default_manager(cls: type[_Self]) -> Manager[_Self, QuerySet[_Self]]: ... # type: ignore[misc]
@property
def _base_manager(cls: type[_Self]) -> Manager[_Self]: ... # type: ignore[misc]
def _base_manager(cls: type[_Self]) -> Manager[_Self, QuerySet[_Self]]: ... # type: ignore[misc]

class Model(metaclass=ModelBase):
# Note: these two metaclass generated attributes don't really exist on the 'Model'
Expand Down
22 changes: 12 additions & 10 deletions django-stubs/db/models/fields/related_descriptors.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ from django.utils.functional import cached_property
from typing_extensions import Self, deprecated

_M = TypeVar("_M", bound=Model)
_M_QS = TypeVar("_M_QS", bound=QuerySet[Any], covariant=True, default=QuerySet[_M])
_F = TypeVar("_F", bound=Field)
_From = TypeVar("_From", bound=Model)
_Through = TypeVar("_Through", bound=Model, default=Model)
_To = TypeVar("_To", bound=Model)
_To_QS = TypeVar("_To_QS", bound=QuerySet[Any], covariant=True, default=QuerySet[_To])

class ForeignKeyDeferredAttribute(DeferredAttribute):
field: RelatedField
Expand Down Expand Up @@ -79,7 +81,7 @@ class ReverseOneToOneDescriptor(Generic[_From, _To]):
def __set__(self, instance: _From, value: _To | None) -> None: ...
def __reduce__(self) -> tuple[Callable[..., Any], tuple[type[_To], str]]: ...

class ReverseManyToOneDescriptor(Generic[_To]):
class ReverseManyToOneDescriptor(Generic[_To, _To_QS]):
"""
In the example::

Expand All @@ -93,16 +95,16 @@ class ReverseManyToOneDescriptor(Generic[_To]):
field: ForeignKey[_To, _To]
def __init__(self, rel: ManyToOneRel) -> None: ...
@cached_property
def related_manager_cls(self) -> type[RelatedManager[_To]]: ...
def related_manager_cls(self) -> type[RelatedManager[_To, _To_QS]]: ...
@overload
def __get__(self, instance: None, cls: Any | None = None) -> Self: ...
def __get__(self, instance: None, cls: Any | None = None) -> ReverseManyToOneDescriptor[_To, _To_QS]: ...
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to return Self[_To, _To_QS] but it's not supported. It's probably fine because I don't expect people to subclass this ?

@overload
def __get__(self, instance: Model, cls: Any | None = None) -> RelatedManager[_To]: ...
def __get__(self, instance: Model, cls: Any | None = None) -> RelatedManager[_To, _To_QS]: ...
def __set__(self, instance: Any, value: Any) -> NoReturn: ...

# Fake class, Django defines 'RelatedManager' inside a function body
@type_check_only
class RelatedManager(Manager[_To], Generic[_To]):
class RelatedManager(Manager[_To, _To_QS], Generic[_To, _To_QS]):
related_val: tuple[int, ...]
def add(self, *objs: _To | int, bulk: bool = ...) -> None: ...
async def aadd(self, *objs: _To | int, bulk: bool = ...) -> None: ...
Expand All @@ -112,23 +114,23 @@ class RelatedManager(Manager[_To], Generic[_To]):
async def aclear(self, *, clear: bool = ...) -> None: ...
def set(
self,
objs: QuerySet[_To] | Iterable[_To | int],
objs: _To_QS | Iterable[_To | int],
*,
bulk: bool = ...,
clear: bool = ...,
) -> None: ...
async def aset(
self,
objs: QuerySet[_To] | Iterable[_To | int],
objs: _To_QS | Iterable[_To | int],
*,
bulk: bool = ...,
clear: bool = ...,
) -> None: ...
def __call__(self, *, manager: str) -> RelatedManager[_To]: ...
def __call__(self, *, manager: str) -> RelatedManager[_To, _To_QS]: ...

def create_reverse_many_to_one_manager(
superclass: type[BaseManager[_M]], rel: ManyToOneRel
) -> type[RelatedManager[_M]]: ...
superclass: type[BaseManager[_M, _M_QS]], rel: ManyToOneRel
) -> type[RelatedManager[_M, _M_QS]]: ...

class ManyToManyDescriptor(ReverseManyToOneDescriptor, Generic[_To, _Through]):
"""
Expand Down
56 changes: 35 additions & 21 deletions django-stubs/db/models/manager.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ from django.db.models.query import QuerySet, RawQuerySet
from typing_extensions import Self

_T = TypeVar("_T", bound=Model, covariant=True)
_QS = TypeVar("_QS", bound=QuerySet[Any], covariant=True, default=QuerySet[_T])

class BaseManager(Generic[_T]):
class BaseManager(Generic[_T, _QS]):
creation_counter: int
auto_created: bool
use_in_migrations: bool
name: str
model: type[_T]
_queryset_class: type[_QS]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I needed to expose this symbol because I use it in the plugin.
If we don't want that, I can also add a type-ignore in the plugin code but I think it's fine to have it because it's a core component of a manager and very unlikely to disappear

_db: str | None
def __new__(cls, *args: Any, **kwargs: Any) -> Self: ...
def __init__(self) -> None: ...
Expand All @@ -24,17 +26,25 @@ class BaseManager(Generic[_T]):
) -> tuple[bool, str | None, str | None, Sequence[Any] | None, dict[str, Any] | None]: ...
def check(self, **kwargs: Any) -> list[Any]: ...
@classmethod
def from_queryset(cls, queryset_class: type[QuerySet[_T]], class_name: str | None = None) -> type[Self]: ...
def from_queryset(
cls, queryset_class: type[QuerySet[_T]], class_name: str | None = None
) -> type[BaseManager[_T, _QS]]: ...
@classmethod
def _get_queryset_methods(cls, queryset_class: type) -> dict[str, Any]: ...
def contribute_to_class(self, cls: type[Model], name: str) -> None: ...
def db_manager(self, using: str | None = None, hints: dict[str, Model] | None = None) -> Self: ...
@property
def db(self) -> str: ...
def get_queryset(self) -> QuerySet[_T]: ...
def all(self) -> QuerySet[_T]: ...
def get_queryset(self) -> _QS: ...
def all(self) -> _QS: ...

class Manager(BaseManager[_T]):
class Manager(BaseManager[_T, _QS]):
# `from_queryset` is redeclared here because Self cannot have type arguments
# ie `def from_queryset(...) -> type[Self[_T, _QS]]` is not valid (mypy raises an error, but resolves type correctly)
@classmethod
def from_queryset(
cls, queryset_class: type[QuerySet[_T]], class_name: str | None = None
) -> type[Manager[_T, _QS]]: ...
# NOTE: The following methods are in common with QuerySet, but note that the use of QuerySet as a return type
# rather than a self-type (_QS), since Manager's QuerySet-like methods return QuerySets and not Managers.
def iterator(self, chunk_size: int | None = ...) -> Iterator[_T]: ...
Expand Down Expand Up @@ -112,24 +122,24 @@ class Manager(BaseManager[_T]):
def datetimes(
self, field_name: str, kind: str, order: str = ..., tzinfo: datetime.tzinfo | None = ...
) -> QuerySet[_T, datetime.datetime]: ...
def none(self) -> QuerySet[_T]: ...
def filter(self, *args: Any, **kwargs: Any) -> QuerySet[_T]: ...
def exclude(self, *args: Any, **kwargs: Any) -> QuerySet[_T]: ...
def complex_filter(self, filter_obj: Any) -> QuerySet[_T]: ...
def none(self) -> _QS: ...
def filter(self, *args: Any, **kwargs: Any) -> _QS: ...
def exclude(self, *args: Any, **kwargs: Any) -> _QS: ...
def complex_filter(self, filter_obj: Any) -> _QS: ...
def count(self) -> int: ...
async def acount(self) -> int: ...
def union(self, *other_qs: Any, all: bool = ...) -> QuerySet[_T]: ...
def intersection(self, *other_qs: Any) -> QuerySet[_T]: ...
def difference(self, *other_qs: Any) -> QuerySet[_T]: ...
def select_for_update(
self, nowait: bool = ..., skip_locked: bool = ..., of: Sequence[str] = ..., no_key: bool = ...
) -> QuerySet[_T]: ...
def select_related(self, *fields: Any) -> QuerySet[_T]: ...
def prefetch_related(self, *lookups: Any) -> QuerySet[_T]: ...
def annotate(self, *args: Any, **kwargs: Any) -> QuerySet[_T]: ...
def alias(self, *args: Any, **kwargs: Any) -> QuerySet[_T]: ...
def order_by(self, *field_names: Any) -> QuerySet[_T]: ...
def distinct(self, *field_names: Any) -> QuerySet[_T]: ...
) -> _QS: ...
def select_related(self, *fields: Any) -> _QS: ...
def prefetch_related(self, *lookups: Any) -> _QS: ...
def annotate(self, *args: Any, **kwargs: Any) -> _QS: ...
def alias(self, *args: Any, **kwargs: Any) -> _QS: ...
def order_by(self, *field_names: Any) -> _QS: ...
def distinct(self, *field_names: Any) -> _QS: ...
# extra() return type won't be supported any time soon
def extra(
self,
Expand All @@ -141,9 +151,9 @@ class Manager(BaseManager[_T]):
select_params: Sequence[Any] | None = ...,
) -> QuerySet[Any]: ...
def reverse(self) -> QuerySet[_T]: ...
def defer(self, *fields: Any) -> QuerySet[_T]: ...
def only(self, *fields: Any) -> QuerySet[_T]: ...
def using(self, alias: str | None) -> QuerySet[_T]: ...
def defer(self, *fields: Any) -> _QS: ...
def only(self, *fields: Any) -> _QS: ...
def using(self, alias: str | None) -> _QS: ...

class ManagerDescriptor:
manager: BaseManager
Expand All @@ -153,5 +163,9 @@ class ManagerDescriptor:
@overload
def __get__(self, instance: Model, cls: type[Model] | None = None) -> NoReturn: ...

class EmptyManager(Manager[_T]):
def __init__(self, model: type[_T]) -> None: ...
# We have to define different typevars here otherwise it conflicts with the ones above
_T2 = TypeVar("_T2", bound=Model, covariant=True)
_QS2 = TypeVar("_QS2", bound=QuerySet[Any], covariant=True, default=QuerySet[_T2])

class EmptyManager(Manager[_T2, _QS2]):
def __init__(self, model: type[_T2]) -> None: ...
Comment on lines +166 to +171
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm really not quite sure why but If I reuse the T and _QS typevar from above, some tests start to fail
For some reason, it was affecting Manager inference and the _T``1 queryset defaults where not all associated.

reveal_type(models.Manager[MyModel])
-note: Revealed type is "django.db.models.manager.Manager[django.db.models.base.Model, django.db.models.query.QuerySet[_T`1, _T`1]]"
+note: Revealed type is "django.db.models.manager.Manager[django.db.models.base.Model, django.db.models.query.QuerySet[django.db.models.base.Model, django.db.models.base.Model]]

2 changes: 1 addition & 1 deletion django-stubs/db/models/query.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ class QuerySet(Generic[_Model, _Row], Iterable[_Row], Sized):
hints: dict[str, Model] | None = None,
) -> None: ...
@classmethod
def as_manager(cls) -> Manager[_Model]: ...
def as_manager(cls) -> Manager[_Model, Self]: ...
def __len__(self) -> int: ...
def __bool__(self) -> bool: ...
def __class_getitem__(cls, item: type[_Model]) -> Self: ...
Expand Down
12 changes: 9 additions & 3 deletions mypy_django_plugin/lib/helpers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from collections import OrderedDict
from collections.abc import Iterable, Iterator
from collections.abc import Iterable, Iterator, Sequence
from typing import TYPE_CHECKING, Any, Literal, cast

from django.db.models.fields import Field
Expand Down Expand Up @@ -563,5 +563,11 @@ def get_model_from_expression(
return None


def fill_manager(manager: TypeInfo, typ: MypyType) -> Instance:
return Instance(manager, [typ] if manager.is_generic() else [])
def fill_instance(typ: TypeInfo, args: Sequence[MypyType]) -> Instance:
"""
The type might not be generic, for ex with user defined managers:

class CustomManager(models.Manager["MyModel"]):
pass
"""
return Instance(typ, args if typ.is_generic() else [])
14 changes: 8 additions & 6 deletions mypy_django_plugin/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
add_as_manager_to_queryset_class,
create_new_manager_class_from_from_queryset_method,
reparametrize_any_manager_hook,
reparametrize_any_queryset_hook,
resolve_manager_method,
)
from mypy_django_plugin.transformers.models import (
Expand Down Expand Up @@ -196,12 +197,13 @@ def get_method_hook(self, fullname: str) -> Callable[[MethodContext], MypyType]

def get_customize_class_mro_hook(self, fullname: str) -> Callable[[ClassDefContext], None] | None:
sym = self.lookup_fully_qualified(fullname)
if (
sym is not None
and isinstance(sym.node, TypeInfo)
and sym.node.has_base(fullnames.BASE_MANAGER_CLASS_FULLNAME)
):
return reparametrize_any_manager_hook
if sym is not None and isinstance(sym.node, TypeInfo):
if sym.node.has_base(fullnames.BASE_MANAGER_CLASS_FULLNAME):
return reparametrize_any_manager_hook
elif sym.node.has_base(fullnames.QUERYSET_CLASS_FULLNAME):
return reparametrize_any_queryset_hook
else:
return None
else:
return None

Expand Down
Loading
Loading