Skip to content

Commit 85e1f3f

Browse files
authored
Mark abstract model classes as abstract classes (in mypy) (#1663)
Mypy requires some attributes to be abstract in order to make the class abstract, so we mark model attributes `<Model>.DoesNotExist` and `<Model>.MultipleObjectsReturned` to be abstract.
1 parent 9e7e915 commit 85e1f3f

File tree

12 files changed

+387
-17
lines changed

12 files changed

+387
-17
lines changed

django-stubs/contrib/auth/base_user.pyi

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ class AbstractBaseUser(models.Model):
1919
password = models.CharField(max_length=128)
2020
last_login = models.DateTimeField(blank=True, null=True)
2121
is_active: bool | BooleanField[bool | Combinable, bool]
22+
23+
class Meta:
24+
abstract: Literal[True]
2225
def get_username(self) -> str: ...
2326
def natural_key(self) -> tuple[str]: ...
2427
@property

django-stubs/contrib/auth/models.pyi

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ class PermissionsMixin(models.Model):
6060
is_superuser = models.BooleanField()
6161
groups = models.ManyToManyField(Group)
6262
user_permissions = models.ManyToManyField(Permission)
63+
64+
class Meta:
65+
abstract: Literal[True]
6366
def get_user_permissions(self, obj: _AnyUser | None = ...) -> set[str]: ...
6467
def get_group_permissions(self, obj: _AnyUser | None = ...) -> set[str]: ...
6568
def get_all_permissions(self, obj: _AnyUser | None = ...) -> set[str]: ...
@@ -80,6 +83,9 @@ class AbstractUser(AbstractBaseUser, PermissionsMixin):
8083

8184
EMAIL_FIELD: str
8285
USERNAME_FIELD: str
86+
87+
class Meta:
88+
abstract: Literal[True]
8389
def get_full_name(self) -> str: ...
8490
def get_short_name(self) -> str: ...
8591
def email_user(

django-stubs/contrib/sessions/base_session.pyi

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from datetime import datetime
2-
from typing import Any, TypeVar
2+
from typing import Any, Literal, TypeVar
33

44
from django.contrib.sessions.backends.base import SessionBase
55
from django.db import models
@@ -15,6 +15,9 @@ class AbstractBaseSession(models.Model):
1515
session_data: str
1616
session_key: str
1717
objects: Any
18+
19+
class Meta:
20+
abstract: Literal[True]
1821
@classmethod
1922
def get_session_store_class(cls) -> type[SessionBase] | None: ...
2023
def get_decoded(self) -> dict[str, Any]: ...

django-stubs/db/models/base.pyi

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ class ModelBase(type):
2727
def _base_manager(cls: type[_Self]) -> BaseManager[_Self]: ... # type: ignore[misc]
2828

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

mypy_django_plugin/lib/fullnames.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,6 @@
5252
ANY_ATTR_ALLOWED_CLASS_FULLNAME = "django_stubs_ext.AnyAttrAllowed"
5353

5454
STR_PROMISE_FULLNAME = "django.utils.functional._StrPromise"
55+
56+
OBJECT_DOES_NOT_EXIST = "django.core.exceptions.ObjectDoesNotExist"
57+
MULTIPLE_OBJECTS_RETURNED = "django.core.exceptions.MultipleObjectsReturned"

mypy_django_plugin/main.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
resolve_manager_method,
3333
)
3434
from mypy_django_plugin.transformers.models import (
35+
MetaclassAdjustments,
3536
handle_annotated_type,
3637
process_model_class,
3738
set_auth_user_model_boolean_fields,
@@ -163,12 +164,13 @@ def get_function_hook(self, fullname: str) -> Optional[Callable[[FunctionContext
163164

164165
if helpers.is_model_subclass_info(info, self.django_context):
165166
return partial(init_create.redefine_and_typecheck_model_init, django_context=self.django_context)
167+
166168
return None
167169

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

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

223225
def get_customize_class_mro_hook(self, fullname: str) -> Optional[Callable[[ClassDefContext], None]]:
226+
if fullname == fullnames.MODEL_CLASS_FULLNAME:
227+
return MetaclassAdjustments.adjust_model_class
228+
224229
sym = self.lookup_fully_qualified(fullname)
225230
if (
226231
sym is not None

mypy_django_plugin/transformers/init_create.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,6 @@ def typecheck_model_method(
4949
error_message=f'Incompatible type for "{actual_name}" of "{model_cls.__name__}"',
5050
)
5151

52-
if model_cls._meta.abstract:
53-
ctx.api.fail(
54-
f'Cannot instantiate abstract model "{model_cls.__name__}"',
55-
ctx.context,
56-
)
57-
5852
return ctx.default_return_type
5953

6054

mypy_django_plugin/transformers/models.py

Lines changed: 138 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
1+
from functools import cached_property
12
from typing import Any, Dict, List, Optional, Type, Union, cast
23

34
from django.db.models import Manager, Model
45
from django.db.models.fields import DateField, DateTimeField, Field
56
from django.db.models.fields.reverse_related import ForeignObjectRel, OneToOneRel
67
from mypy.checker import TypeChecker
7-
from mypy.nodes import ARG_STAR2, Argument, AssignmentStmt, CallExpr, Context, NameExpr, TypeInfo, Var
8+
from mypy.nodes import (
9+
ARG_STAR2,
10+
MDEF,
11+
Argument,
12+
AssignmentStmt,
13+
CallExpr,
14+
Context,
15+
NameExpr,
16+
SymbolTableNode,
17+
TypeInfo,
18+
Var,
19+
)
820
from mypy.plugin import AnalyzeTypeContext, AttributeContext, CheckerPluginInterface, ClassDefContext
921
from mypy.plugins import common
1022
from mypy.semanal import SemanticAnalyzer
11-
from mypy.types import AnyType, Instance, TypedDictType, TypeOfAny, get_proper_type
23+
from mypy.types import AnyType, Instance, LiteralType, TypedDictType, TypeOfAny, TypeType, get_proper_type
1224
from mypy.types import Type as MypyType
1325
from mypy.typevars import fill_typevars
1426

@@ -168,7 +180,7 @@ def get_or_create_queryset_with_any_fallback(self) -> TypeInfo:
168180
return queryset_info
169181

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

173185

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

589601

602+
class MetaclassAdjustments(ModelClassInitializer):
603+
@classmethod
604+
def adjust_model_class(cls, ctx: ClassDefContext) -> None:
605+
"""
606+
For the sake of type checkers other than mypy, some attributes that are
607+
dynamically added by Django's model metaclass has been annotated on
608+
`django.db.models.base.Model`. We remove those attributes and will handle them
609+
through the plugin.
610+
"""
611+
if ctx.cls.fullname != fullnames.MODEL_CLASS_FULLNAME:
612+
return
613+
614+
does_not_exist = ctx.cls.info.names.get("DoesNotExist")
615+
if does_not_exist is not None and isinstance(does_not_exist.node, Var) and not does_not_exist.plugin_generated:
616+
del ctx.cls.info.names["DoesNotExist"]
617+
618+
multiple_objects_returned = ctx.cls.info.names.get("MultipleObjectsReturned")
619+
if (
620+
multiple_objects_returned is not None
621+
and isinstance(multiple_objects_returned.node, Var)
622+
and not multiple_objects_returned.plugin_generated
623+
):
624+
del ctx.cls.info.names["MultipleObjectsReturned"]
625+
626+
return
627+
628+
def get_exception_bases(self, name: str) -> List[Instance]:
629+
bases = []
630+
for model_base in self.model_classdef.info.direct_base_classes():
631+
exception_base_sym = model_base.names.get(name)
632+
if (
633+
# Base class is a Model
634+
model_base.metaclass_type is not None
635+
and model_base.metaclass_type.type.fullname == fullnames.MODEL_METACLASS_FULLNAME
636+
# But base class is not 'models.Model'
637+
and model_base.fullname != fullnames.MODEL_CLASS_FULLNAME
638+
# Base class also has a generated exception base e.g. 'DoesNotExist'
639+
and exception_base_sym is not None
640+
and exception_base_sym.plugin_generated
641+
and isinstance(exception_base_sym.node, TypeInfo)
642+
):
643+
bases.append(Instance(exception_base_sym.node, []))
644+
645+
return bases
646+
647+
@cached_property
648+
def is_model_abstract(self) -> bool:
649+
meta = self.model_classdef.info.names.get("Meta")
650+
# Check if 'abstract' is declared in this model's 'class Meta' as
651+
# 'abstract = True' won't be inherited from a parent model.
652+
if meta is not None and isinstance(meta.node, TypeInfo) and "abstract" in meta.node.names:
653+
for stmt in meta.node.defn.defs.body:
654+
if (
655+
# abstract =
656+
isinstance(stmt, AssignmentStmt)
657+
and len(stmt.lvalues) == 1
658+
and isinstance(stmt.lvalues[0], NameExpr)
659+
and stmt.lvalues[0].name == "abstract"
660+
):
661+
# abstract = True (builtins.bool)
662+
rhs_is_true = (
663+
isinstance(stmt.rvalue, NameExpr)
664+
and stmt.rvalue.name == "True"
665+
and isinstance(stmt.rvalue.node, Var)
666+
and isinstance(stmt.rvalue.node.type, Instance)
667+
and stmt.rvalue.node.type.type.fullname == "builtins.bool"
668+
)
669+
# abstract: Literal[True]
670+
is_literal_true = isinstance(stmt.type, LiteralType) and stmt.type.value is True
671+
return rhs_is_true or is_literal_true
672+
return False
673+
674+
def add_exception_classes(self) -> None:
675+
"""
676+
Adds exception classes 'DoesNotExist' and 'MultipleObjectsReturned' to a model
677+
type, aligned with how the model metaclass does it runtime.
678+
679+
If the model is abstract, exceptions will be added as abstract attributes.
680+
"""
681+
if "DoesNotExist" not in self.model_classdef.info.names:
682+
object_does_not_exist = self.lookup_typeinfo_or_incomplete_defn_error(fullnames.OBJECT_DOES_NOT_EXIST)
683+
does_not_exist: Union[Var, TypeInfo]
684+
if self.is_model_abstract:
685+
does_not_exist = self.create_new_var("DoesNotExist", TypeType(Instance(object_does_not_exist, [])))
686+
does_not_exist.is_abstract_var = True
687+
else:
688+
does_not_exist = helpers.create_type_info(
689+
"DoesNotExist",
690+
self.model_classdef.info.fullname,
691+
self.get_exception_bases("DoesNotExist") or [Instance(object_does_not_exist, [])],
692+
)
693+
self.model_classdef.info.names[does_not_exist.name] = SymbolTableNode(
694+
MDEF, does_not_exist, plugin_generated=True
695+
)
696+
697+
if "MultipleObjectsReturned" not in self.model_classdef.info.names:
698+
django_multiple_objects_returned = self.lookup_typeinfo_or_incomplete_defn_error(
699+
fullnames.MULTIPLE_OBJECTS_RETURNED
700+
)
701+
multiple_objects_returned: Union[Var, TypeInfo]
702+
if self.is_model_abstract:
703+
multiple_objects_returned = self.create_new_var(
704+
"MultipleObjectsReturned", TypeType(Instance(django_multiple_objects_returned, []))
705+
)
706+
multiple_objects_returned.is_abstract_var = True
707+
else:
708+
multiple_objects_returned = helpers.create_type_info(
709+
"MultipleObjectsReturned",
710+
self.model_classdef.info.fullname,
711+
(
712+
self.get_exception_bases("MultipleObjectsReturned")
713+
or [Instance(django_multiple_objects_returned, [])]
714+
),
715+
)
716+
self.model_classdef.info.names[multiple_objects_returned.name] = SymbolTableNode(
717+
MDEF, multiple_objects_returned, plugin_generated=True
718+
)
719+
720+
def run(self) -> None:
721+
self.add_exception_classes()
722+
723+
590724
def process_model_class(ctx: ClassDefContext, django_context: DjangoContext) -> None:
591725
initializers = [
592726
InjectAnyAsBaseForNestedMeta,
@@ -598,6 +732,7 @@ def process_model_class(ctx: ClassDefContext, django_context: DjangoContext) ->
598732
AddRelatedManagers,
599733
AddExtraFieldMethods,
600734
AddMetaOptionsAttribute,
735+
MetaclassAdjustments,
601736
]
602737
for initializer_cls in initializers:
603738
try:

0 commit comments

Comments
 (0)