Skip to content
Merged
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
3 changes: 3 additions & 0 deletions django-stubs/contrib/auth/base_user.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ class AbstractBaseUser(models.Model):
password = models.CharField(max_length=128)
last_login = models.DateTimeField(blank=True, null=True)
is_active: bool | BooleanField[bool | Combinable, bool]

class Meta:
abstract: Literal[True]
def get_username(self) -> str: ...
def natural_key(self) -> tuple[str]: ...
@property
Expand Down
6 changes: 6 additions & 0 deletions django-stubs/contrib/auth/models.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ class PermissionsMixin(models.Model):
is_superuser = models.BooleanField()
groups = models.ManyToManyField(Group)
user_permissions = models.ManyToManyField(Permission)

class Meta:
abstract: Literal[True]
def get_user_permissions(self, obj: _AnyUser | None = ...) -> set[str]: ...
def get_group_permissions(self, obj: _AnyUser | None = ...) -> set[str]: ...
def get_all_permissions(self, obj: _AnyUser | None = ...) -> set[str]: ...
Expand All @@ -80,6 +83,9 @@ class AbstractUser(AbstractBaseUser, PermissionsMixin):

EMAIL_FIELD: str
USERNAME_FIELD: str

class Meta:
abstract: Literal[True]
def get_full_name(self) -> str: ...
def get_short_name(self) -> str: ...
def email_user(
Expand Down
5 changes: 4 additions & 1 deletion django-stubs/contrib/sessions/base_session.pyi
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from datetime import datetime
from typing import Any, TypeVar
from typing import Any, Literal, TypeVar

from django.contrib.sessions.backends.base import SessionBase
from django.db import models
Expand All @@ -15,6 +15,9 @@ class AbstractBaseSession(models.Model):
session_data: str
session_key: str
objects: Any

class Meta:
abstract: Literal[True]
@classmethod
def get_session_store_class(cls) -> type[SessionBase] | None: ...
def get_decoded(self) -> dict[str, Any]: ...
5 changes: 5 additions & 0 deletions django-stubs/db/models/base.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ class ModelBase(type):
def _base_manager(cls: type[_Self]) -> BaseManager[_Self]: ... # type: ignore[misc]

class Model(metaclass=ModelBase):
# Note: these two metaclass generated attributes don't really exist on the 'Model'
# class, runtime they are only added on concrete subclasses of 'Model'. The
# metaclass also sets up correct inheritance from concrete parent models exceptions.
# Our mypy plugin aligns with this behaviour and will remove the 2 attributes below
# and re-add them to correct concrete subclasses of 'Model'
DoesNotExist: Final[type[ObjectDoesNotExist]]
MultipleObjectsReturned: Final[type[BaseMultipleObjectsReturned]]

Expand Down
3 changes: 3 additions & 0 deletions mypy_django_plugin/lib/fullnames.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,6 @@
ANY_ATTR_ALLOWED_CLASS_FULLNAME = "django_stubs_ext.AnyAttrAllowed"

STR_PROMISE_FULLNAME = "django.utils.functional._StrPromise"

OBJECT_DOES_NOT_EXIST = "django.core.exceptions.ObjectDoesNotExist"
MULTIPLE_OBJECTS_RETURNED = "django.core.exceptions.MultipleObjectsReturned"
9 changes: 7 additions & 2 deletions mypy_django_plugin/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
resolve_manager_method,
)
from mypy_django_plugin.transformers.models import (
MetaclassAdjustments,
handle_annotated_type,
process_model_class,
set_auth_user_model_boolean_fields,
Expand Down Expand Up @@ -163,12 +164,13 @@ def get_function_hook(self, fullname: str) -> Optional[Callable[[FunctionContext

if helpers.is_model_subclass_info(info, self.django_context):
return partial(init_create.redefine_and_typecheck_model_init, django_context=self.django_context)

return None

def get_method_hook(self, fullname: str) -> Optional[Callable[[MethodContext], MypyType]]:
class_fullname, _, method_name = fullname.rpartition(".")
# It is looked up very often, specialcase this method for minor speed up
if method_name == "__init_subclass__":
# Methods called very often -- short circuit for minor speed up
if method_name == "__init_subclass__" or fullname.startswith("builtins."):
return None

if class_fullname.endswith("QueryDict"):
Expand Down Expand Up @@ -221,6 +223,9 @@ def get_method_hook(self, fullname: str) -> Optional[Callable[[MethodContext], M
return None

def get_customize_class_mro_hook(self, fullname: str) -> Optional[Callable[[ClassDefContext], None]]:
if fullname == fullnames.MODEL_CLASS_FULLNAME:
return MetaclassAdjustments.adjust_model_class

sym = self.lookup_fully_qualified(fullname)
if (
sym is not None
Expand Down
6 changes: 0 additions & 6 deletions mypy_django_plugin/transformers/init_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,6 @@ def typecheck_model_method(
error_message=f'Incompatible type for "{actual_name}" of "{model_cls.__name__}"',
)

if model_cls._meta.abstract:
ctx.api.fail(
f'Cannot instantiate abstract model "{model_cls.__name__}"',
ctx.context,
)

return ctx.default_return_type


Expand Down
141 changes: 138 additions & 3 deletions mypy_django_plugin/transformers/models.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
from functools import cached_property
from typing import Any, Dict, List, Optional, Type, Union, cast

from django.db.models import Manager, Model
from django.db.models.fields import DateField, DateTimeField, Field
from django.db.models.fields.reverse_related import ForeignObjectRel, OneToOneRel
from mypy.checker import TypeChecker
from mypy.nodes import ARG_STAR2, Argument, AssignmentStmt, CallExpr, Context, NameExpr, TypeInfo, Var
from mypy.nodes import (
ARG_STAR2,
MDEF,
Argument,
AssignmentStmt,
CallExpr,
Context,
NameExpr,
SymbolTableNode,
TypeInfo,
Var,
)
from mypy.plugin import AnalyzeTypeContext, AttributeContext, CheckerPluginInterface, ClassDefContext
from mypy.plugins import common
from mypy.semanal import SemanticAnalyzer
from mypy.types import AnyType, Instance, TypedDictType, TypeOfAny, get_proper_type
from mypy.types import AnyType, Instance, LiteralType, TypedDictType, TypeOfAny, TypeType, get_proper_type
from mypy.types import Type as MypyType
from mypy.typevars import fill_typevars

Expand Down Expand Up @@ -168,7 +180,7 @@ def get_or_create_queryset_with_any_fallback(self) -> TypeInfo:
return queryset_info

def run_with_model_cls(self, model_cls: Type[Model]) -> None:
raise NotImplementedError("Implement this in subclasses")
raise NotImplementedError(f"Implement this in subclass {self.__class__.__name__}")


class InjectAnyAsBaseForNestedMeta(ModelClassInitializer):
Expand Down Expand Up @@ -587,6 +599,128 @@ def run_with_model_cls(self, model_cls: Type[Model]) -> None:
self.add_new_node_to_model_class("_meta", Instance(options_info, [Instance(self.model_classdef.info, [])]))


class MetaclassAdjustments(ModelClassInitializer):
@classmethod
def adjust_model_class(cls, ctx: ClassDefContext) -> None:
"""
For the sake of type checkers other than mypy, some attributes that are
dynamically added by Django's model metaclass has been annotated on
`django.db.models.base.Model`. We remove those attributes and will handle them
through the plugin.
"""
if ctx.cls.fullname != fullnames.MODEL_CLASS_FULLNAME:
return

does_not_exist = ctx.cls.info.names.get("DoesNotExist")
if does_not_exist is not None and isinstance(does_not_exist.node, Var) and not does_not_exist.plugin_generated:
del ctx.cls.info.names["DoesNotExist"]

multiple_objects_returned = ctx.cls.info.names.get("MultipleObjectsReturned")
if (
multiple_objects_returned is not None
and isinstance(multiple_objects_returned.node, Var)
and not multiple_objects_returned.plugin_generated
):
del ctx.cls.info.names["MultipleObjectsReturned"]

return

def get_exception_bases(self, name: str) -> List[Instance]:
bases = []
for model_base in self.model_classdef.info.direct_base_classes():
exception_base_sym = model_base.names.get(name)
if (
# Base class is a Model
model_base.metaclass_type is not None
and model_base.metaclass_type.type.fullname == fullnames.MODEL_METACLASS_FULLNAME
# But base class is not 'models.Model'
and model_base.fullname != fullnames.MODEL_CLASS_FULLNAME
# Base class also has a generated exception base e.g. 'DoesNotExist'
and exception_base_sym is not None
and exception_base_sym.plugin_generated
and isinstance(exception_base_sym.node, TypeInfo)
):
bases.append(Instance(exception_base_sym.node, []))

return bases

@cached_property
def is_model_abstract(self) -> bool:
meta = self.model_classdef.info.names.get("Meta")
# Check if 'abstract' is declared in this model's 'class Meta' as
# 'abstract = True' won't be inherited from a parent model.
if meta is not None and isinstance(meta.node, TypeInfo) and "abstract" in meta.node.names:
for stmt in meta.node.defn.defs.body:
if (
# abstract =
isinstance(stmt, AssignmentStmt)
and len(stmt.lvalues) == 1
and isinstance(stmt.lvalues[0], NameExpr)
and stmt.lvalues[0].name == "abstract"
):
# abstract = True (builtins.bool)
rhs_is_true = (
isinstance(stmt.rvalue, NameExpr)
and stmt.rvalue.name == "True"
and isinstance(stmt.rvalue.node, Var)
and isinstance(stmt.rvalue.node.type, Instance)
and stmt.rvalue.node.type.type.fullname == "builtins.bool"
)
# abstract: Literal[True]
is_literal_true = isinstance(stmt.type, LiteralType) and stmt.type.value is True
return rhs_is_true or is_literal_true
return False

def add_exception_classes(self) -> None:
"""
Adds exception classes 'DoesNotExist' and 'MultipleObjectsReturned' to a model
type, aligned with how the model metaclass does it runtime.

If the model is abstract, exceptions will be added as abstract attributes.
"""
if "DoesNotExist" not in self.model_classdef.info.names:
object_does_not_exist = self.lookup_typeinfo_or_incomplete_defn_error(fullnames.OBJECT_DOES_NOT_EXIST)
does_not_exist: Union[Var, TypeInfo]
if self.is_model_abstract:
does_not_exist = self.create_new_var("DoesNotExist", TypeType(Instance(object_does_not_exist, [])))
does_not_exist.is_abstract_var = True
else:
does_not_exist = helpers.create_type_info(
"DoesNotExist",
self.model_classdef.info.fullname,
self.get_exception_bases("DoesNotExist") or [Instance(object_does_not_exist, [])],
)
self.model_classdef.info.names[does_not_exist.name] = SymbolTableNode(
MDEF, does_not_exist, plugin_generated=True
)

if "MultipleObjectsReturned" not in self.model_classdef.info.names:
django_multiple_objects_returned = self.lookup_typeinfo_or_incomplete_defn_error(
fullnames.MULTIPLE_OBJECTS_RETURNED
)
multiple_objects_returned: Union[Var, TypeInfo]
if self.is_model_abstract:
multiple_objects_returned = self.create_new_var(
"MultipleObjectsReturned", TypeType(Instance(django_multiple_objects_returned, []))
)
multiple_objects_returned.is_abstract_var = True
else:
multiple_objects_returned = helpers.create_type_info(
"MultipleObjectsReturned",
self.model_classdef.info.fullname,
(
self.get_exception_bases("MultipleObjectsReturned")
or [Instance(django_multiple_objects_returned, [])]
),
)
self.model_classdef.info.names[multiple_objects_returned.name] = SymbolTableNode(
MDEF, multiple_objects_returned, plugin_generated=True
)

def run(self) -> None:
self.add_exception_classes()


def process_model_class(ctx: ClassDefContext, django_context: DjangoContext) -> None:
initializers = [
InjectAnyAsBaseForNestedMeta,
Expand All @@ -598,6 +732,7 @@ def process_model_class(ctx: ClassDefContext, django_context: DjangoContext) ->
AddRelatedManagers,
AddExtraFieldMethods,
AddMetaOptionsAttribute,
MetaclassAdjustments,
]
for initializer_cls in initializers:
try:
Expand Down
Loading