diff --git a/django-stubs/contrib/auth/__init__.pyi b/django-stubs/contrib/auth/__init__.pyi index 0d24bb9b0..72b6d46aa 100644 --- a/django-stubs/contrib/auth/__init__.pyi +++ b/django-stubs/contrib/auth/__init__.pyi @@ -16,16 +16,17 @@ BACKEND_SESSION_KEY: str HASH_SESSION_KEY: str REDIRECT_FIELD_NAME: str +def get_user_model() -> type[AbstractBaseUser]: ... + +_UserModel = get_user_model() + def load_backend(path: str) -> ModelBackend: ... def get_backends() -> list[ModelBackend]: ... -def authenticate(request: HttpRequest | None = ..., **credentials: Any) -> AbstractBaseUser | None: ... -def login( - request: HttpRequest, user: AbstractBaseUser | None, backend: type[ModelBackend] | str | None = ... -) -> None: ... +def authenticate(request: HttpRequest | None = ..., **credentials: Any) -> _UserModel | None: ... +def login(request: HttpRequest, user: _UserModel | None, backend: type[ModelBackend] | str | None = ...) -> None: ... def logout(request: HttpRequest) -> None: ... -def get_user_model() -> type[AbstractBaseUser]: ... -def get_user(request: HttpRequest | Client) -> AbstractBaseUser | AnonymousUser: ... +def get_user(request: HttpRequest | Client) -> _UserModel | AnonymousUser: ... def get_permission_codename(action: str, opts: Options) -> str: ... -def update_session_auth_hash(request: HttpRequest, user: AbstractBaseUser) -> None: ... +def update_session_auth_hash(request: HttpRequest, user: _UserModel) -> None: ... default_app_config: str diff --git a/django-stubs/contrib/auth/backends.pyi b/django-stubs/contrib/auth/backends.pyi index 86c68c985..1e8a2c973 100644 --- a/django-stubs/contrib/auth/backends.pyi +++ b/django-stubs/contrib/auth/backends.pyi @@ -1,19 +1,18 @@ from typing import Any, TypeVar -from django.contrib.auth.base_user import AbstractBaseUser +from django.contrib.auth import _UserModel from django.contrib.auth.models import AnonymousUser, Permission from django.db.models import QuerySet from django.db.models.base import Model from django.http.request import HttpRequest from typing_extensions import TypeAlias -_AnyUser: TypeAlias = AbstractBaseUser | AnonymousUser - -UserModel: Any +UserModel: TypeAlias = _UserModel +_AnyUser: TypeAlias = UserModel | AnonymousUser class BaseBackend: - def authenticate(self, request: HttpRequest | None, **kwargs: Any) -> AbstractBaseUser | None: ... - def get_user(self, user_id: int) -> AbstractBaseUser | None: ... + def authenticate(self, request: HttpRequest | None, **kwargs: Any) -> UserModel | None: ... + def get_user(self, user_id: int) -> UserModel | None: ... def get_user_permissions(self, user_obj: _AnyUser, obj: Model | None = ...) -> set[str]: ... def get_group_permissions(self, user_obj: _AnyUser, obj: Model | None = ...) -> set[str]: ... def get_all_permissions(self, user_obj: _AnyUser, obj: Model | None = ...) -> set[str]: ... @@ -22,7 +21,7 @@ class BaseBackend: class ModelBackend(BaseBackend): def authenticate( self, request: HttpRequest | None, username: str | None = ..., password: str | None = ..., **kwargs: Any - ) -> AbstractBaseUser | None: ... + ) -> UserModel | None: ... def has_module_perms(self, user_obj: _AnyUser, app_label: str) -> bool: ... def user_can_authenticate(self, user: _AnyUser | None) -> bool: ... def with_perm( @@ -31,11 +30,11 @@ class ModelBackend(BaseBackend): is_active: bool = ..., include_superusers: bool = ..., obj: Model | None = ..., - ) -> QuerySet[AbstractBaseUser]: ... + ) -> QuerySet[UserModel]: ... class AllowAllUsersModelBackend(ModelBackend): ... -_U = TypeVar("_U", bound=AbstractBaseUser) +_U = TypeVar("_U", bound=UserModel) class RemoteUserBackend(ModelBackend): create_unknown_user: bool diff --git a/django-stubs/contrib/auth/decorators.pyi b/django-stubs/contrib/auth/decorators.pyi index c4954e91b..f3eb040bf 100644 --- a/django-stubs/contrib/auth/decorators.pyi +++ b/django-stubs/contrib/auth/decorators.pyi @@ -1,13 +1,14 @@ from collections.abc import Callable, Iterable from typing import TypeVar, overload -from django.contrib.auth.models import AbstractBaseUser, AnonymousUser +from django.contrib.auth import _UserModel +from django.contrib.auth.models import AnonymousUser from django.http.response import HttpResponseBase _VIEW = TypeVar("_VIEW", bound=Callable[..., HttpResponseBase]) def user_passes_test( - test_func: Callable[[AbstractBaseUser | AnonymousUser], bool], + test_func: Callable[[_UserModel | AnonymousUser], bool], login_url: str | None = ..., redirect_field_name: str | None = ..., ) -> Callable[[_VIEW], _VIEW]: ... diff --git a/django-stubs/contrib/auth/middleware.pyi b/django-stubs/contrib/auth/middleware.pyi index 4d25b66d0..a899eade1 100644 --- a/django-stubs/contrib/auth/middleware.pyi +++ b/django-stubs/contrib/auth/middleware.pyi @@ -1,9 +1,9 @@ -from django.contrib.auth.base_user import AbstractBaseUser +from django.contrib.auth import _UserModel from django.contrib.auth.models import AnonymousUser from django.http.request import HttpRequest from django.utils.deprecation import MiddlewareMixin -def get_user(request: HttpRequest) -> AnonymousUser | AbstractBaseUser: ... +def get_user(request: HttpRequest) -> AnonymousUser | _UserModel: ... class AuthenticationMiddleware(MiddlewareMixin): def process_request(self, request: HttpRequest) -> None: ... diff --git a/django-stubs/contrib/auth/password_validation.pyi b/django-stubs/contrib/auth/password_validation.pyi index 806d87871..cf6d3071d 100644 --- a/django-stubs/contrib/auth/password_validation.pyi +++ b/django-stubs/contrib/auth/password_validation.pyi @@ -2,10 +2,7 @@ from collections.abc import Mapping, Sequence from pathlib import Path, PosixPath from typing import Any, Protocol -from django.db.models.base import Model -from typing_extensions import TypeAlias - -_UserModel: TypeAlias = Model +from django.contrib.auth import _UserModel class PasswordValidator(Protocol): def validate(self, __password: str, __user: _UserModel | None = ...) -> None: ... diff --git a/django-stubs/contrib/auth/tokens.pyi b/django-stubs/contrib/auth/tokens.pyi index 21692dd72..db6ddf6e5 100644 --- a/django-stubs/contrib/auth/tokens.pyi +++ b/django-stubs/contrib/auth/tokens.pyi @@ -1,17 +1,17 @@ from datetime import date, datetime from typing import Any -from django.contrib.auth.base_user import AbstractBaseUser +from django.contrib.auth import _UserModel class PasswordResetTokenGenerator: key_salt: str secret: str | bytes secret_fallbacks: list[str | bytes] algorithm: str - def make_token(self, user: AbstractBaseUser) -> str: ... - def check_token(self, user: AbstractBaseUser | None, token: str | None) -> bool: ... - def _make_token_with_timestamp(self, user: AbstractBaseUser, timestamp: int, secret: str | bytes = ...) -> str: ... - def _make_hash_value(self, user: AbstractBaseUser, timestamp: int) -> str: ... + def make_token(self, user: _UserModel) -> str: ... + def check_token(self, user: _UserModel | None, token: str | None) -> bool: ... + def _make_token_with_timestamp(self, user: _UserModel, timestamp: int, secret: str | bytes = ...) -> str: ... + def _make_hash_value(self, user: _UserModel, timestamp: int) -> str: ... def _num_seconds(self, dt: datetime | date) -> int: ... def _now(self) -> datetime: ... diff --git a/django-stubs/contrib/auth/views.pyi b/django-stubs/contrib/auth/views.pyi index 8992d334a..3f4ed90a0 100644 --- a/django-stubs/contrib/auth/views.pyi +++ b/django-stubs/contrib/auth/views.pyi @@ -1,14 +1,12 @@ from typing import Any -from django.contrib.auth.base_user import AbstractBaseUser +from django.contrib.auth import _UserModel from django.contrib.auth.forms import AuthenticationForm from django.http.request import HttpRequest from django.http.response import HttpResponse, HttpResponseRedirect from django.views.generic.base import TemplateView from django.views.generic.edit import FormView -UserModel: Any - class RedirectURLMixin: next_page: str | None redirect_field_name: str @@ -65,7 +63,7 @@ class PasswordResetConfirmView(PasswordContextMixin, FormView): token_generator: Any validlink: bool user: Any - def get_user(self, uidb64: str) -> AbstractBaseUser | None: ... + def get_user(self, uidb64: str) -> _UserModel | None: ... class PasswordResetCompleteView(PasswordContextMixin, TemplateView): title: Any diff --git a/django-stubs/http/request.pyi b/django-stubs/http/request.pyi index b5ebf8b48..e5d3ce91c 100644 --- a/django-stubs/http/request.pyi +++ b/django-stubs/http/request.pyi @@ -3,7 +3,7 @@ from io import BytesIO from re import Pattern from typing import Any, BinaryIO, Literal, NoReturn, TypeVar, overload -from django.contrib.auth.base_user import AbstractBaseUser +from django.contrib.auth import _UserModel from django.contrib.auth.models import AnonymousUser from django.contrib.sessions.backends.base import SessionBase from django.contrib.sites.models import Site @@ -45,7 +45,7 @@ class HttpRequest(BytesIO): # django.contrib.admin views: current_app: str # django.contrib.auth.middleware.AuthenticationMiddleware: - user: AbstractBaseUser | AnonymousUser + user: _UserModel | AnonymousUser # django.middleware.locale.LocaleMiddleware: LANGUAGE_CODE: str # django.contrib.sites.middleware.CurrentSiteMiddleware diff --git a/django-stubs/test/client.pyi b/django-stubs/test/client.pyi index 43a4a1e97..ec7167c23 100644 --- a/django-stubs/test/client.pyi +++ b/django-stubs/test/client.pyi @@ -5,7 +5,7 @@ from re import Pattern from types import TracebackType from typing import Any, Generic, NoReturn, TypeVar -from django.contrib.auth.base_user import AbstractBaseUser +from django.contrib.auth import _UserModel from django.contrib.sessions.backends.base import SessionBase from django.core.handlers.asgi import ASGIRequest from django.core.handlers.base import BaseHandler @@ -180,7 +180,7 @@ class ClientMixin: @property def session(self) -> SessionBase: ... def login(self, **credentials: Any) -> bool: ... - def force_login(self, user: AbstractBaseUser, backend: str | None = ...) -> None: ... + def force_login(self, user: _UserModel, backend: str | None = ...) -> None: ... def logout(self) -> None: ... class Client(ClientMixin, _RequestFactory[_MonkeyPatchedWSGIResponse]): diff --git a/mypy_django_plugin/lib/fullnames.py b/mypy_django_plugin/lib/fullnames.py index 33a2e1181..0a97fab6c 100644 --- a/mypy_django_plugin/lib/fullnames.py +++ b/mypy_django_plugin/lib/fullnames.py @@ -1,4 +1,6 @@ +GET_USER_MODEL_FULLNAME = "django.contrib.auth.get_user_model" ABSTRACT_USER_MODEL_FULLNAME = "django.contrib.auth.models.AbstractUser" +ABSTRACT_BASE_USER_MODEL_FULLNAME = "django.contrib.auth.base_user.AbstractBaseUser" PERMISSION_MIXIN_CLASS_FULLNAME = "django.contrib.auth.models.PermissionsMixin" MODEL_METACLASS_FULLNAME = "django.db.models.base.ModelBase" MODEL_CLASS_FULLNAME = "django.db.models.base.Model" diff --git a/mypy_django_plugin/main.py b/mypy_django_plugin/main.py index 454a6cb13..722515e30 100644 --- a/mypy_django_plugin/main.py +++ b/mypy_django_plugin/main.py @@ -23,7 +23,7 @@ from mypy_django_plugin.django.context import DjangoContext from mypy_django_plugin.exceptions import UnregisteredModelError from mypy_django_plugin.lib import fullnames, helpers -from mypy_django_plugin.transformers import fields, forms, init_create, meta, querysets, request, settings +from mypy_django_plugin.transformers import fields, forms, init_create, meta, querysets, settings from mypy_django_plugin.transformers.functional import resolve_str_promise_attribute from mypy_django_plugin.transformers.managers import ( create_new_manager_class_from_as_manager_method, @@ -111,15 +111,14 @@ def get_additional_deps(self, file: MypyFile) -> List[Tuple[int, str, int]]: return [self._new_dependency("typing"), self._new_dependency("django_stubs_ext")] # for `get_user_model()` - if self.django_context.settings: - if file.fullname == "django.contrib.auth" or file.fullname in {"django.http", "django.http.request"}: - auth_user_model_name = self.django_context.settings.AUTH_USER_MODEL - try: - auth_user_module = self.django_context.apps_registry.get_model(auth_user_model_name).__module__ - except LookupError: - # get_user_model() model app is not installed - return [] - return [self._new_dependency(auth_user_module), self._new_dependency("django_stubs_ext")] + if file.fullname == "django.contrib.auth" and self.django_context.settings: + auth_user_model_name = self.django_context.settings.AUTH_USER_MODEL + try: + auth_user_module = self.django_context.apps_registry.get_model(auth_user_model_name).__module__ + except LookupError: + # get_user_model() model app is not installed, fall back the the default User model + auth_user_module = "django.contrib.auth.models" + return [self._new_dependency(auth_user_module), self._new_dependency("django_stubs_ext")] # ensure that all mentioned to='someapp.SomeModel' are loaded with corresponding related Fields defined_model_classes = self.django_context.model_modules.get(file.fullname) @@ -273,10 +272,6 @@ def get_attribute_hook(self, fullname: str) -> Optional[Callable[[AttributeConte if info and info.has_base(fullnames.PERMISSION_MIXIN_CLASS_FULLNAME) and attr_name == "is_superuser": return partial(set_auth_user_model_boolean_fields, django_context=self.django_context) - # Lookup of the 'request.user' attribute - if info and info.has_base(fullnames.HTTPREQUEST_CLASS_FULLNAME) and attr_name == "user": - return partial(request.set_auth_user_model_as_type_for_request_user, django_context=self.django_context) - # Lookup of the 'user.is_staff' or 'user.is_active' attribute if info and info.has_base(fullnames.ABSTRACT_USER_MODEL_FULLNAME) and attr_name in ("is_staff", "is_active"): return partial(set_auth_user_model_boolean_fields, django_context=self.django_context) @@ -306,6 +301,9 @@ def get_type_analyze_hook(self, fullname: str) -> Optional[Callable[[AnalyzeType return None def get_dynamic_class_hook(self, fullname: str) -> Optional[Callable[[DynamicClassDefContext], None]]: + if fullname == fullnames.GET_USER_MODEL_FULLNAME and self.django_context.settings: + return partial(settings.transform_get_user_model_hook, django_context=self.django_context) + # Create a new manager class definition when a manager's '.from_queryset' classmethod is called class_name, _, method_name = fullname.rpartition(".") if method_name == "from_queryset": diff --git a/mypy_django_plugin/transformers/request.py b/mypy_django_plugin/transformers/request.py index a77001f12..b173259f5 100644 --- a/mypy_django_plugin/transformers/request.py +++ b/mypy_django_plugin/transformers/request.py @@ -1,39 +1,8 @@ -from mypy.plugin import AttributeContext, MethodContext -from mypy.types import Instance, UninhabitedType, UnionType +from mypy.plugin import MethodContext from mypy.types import Type as MypyType +from mypy.types import UninhabitedType from mypy_django_plugin.django.context import DjangoContext -from mypy_django_plugin.lib import helpers - - -def set_auth_user_model_as_type_for_request_user(ctx: AttributeContext, django_context: DjangoContext) -> MypyType: - if not django_context.apps_registry.is_installed("django.contrib.auth"): - return ctx.default_attr_type - - # Imported here because django isn't properly loaded yet when module is loaded - from django.contrib.auth.base_user import AbstractBaseUser - from django.contrib.auth.models import AnonymousUser - - abstract_base_user_info = helpers.lookup_class_typeinfo(helpers.get_typechecker_api(ctx), AbstractBaseUser) - anonymous_user_info = helpers.lookup_class_typeinfo(helpers.get_typechecker_api(ctx), AnonymousUser) - - # This shouldn't be able to happen, as we managed to import the models above. - assert abstract_base_user_info is not None - assert anonymous_user_info is not None - - if ctx.default_attr_type != UnionType([Instance(abstract_base_user_info, []), Instance(anonymous_user_info, [])]): - # Type has been changed from the default in django-stubs. - # I.e. HttpRequest has been subclassed and user-type overridden, so let's leave it as is. - return ctx.default_attr_type - - auth_user_model = django_context.settings.AUTH_USER_MODEL - user_cls = django_context.apps_registry.get_model(auth_user_model) - user_info = helpers.lookup_class_typeinfo(helpers.get_typechecker_api(ctx), user_cls) - - if user_info is None: - return ctx.default_attr_type - - return UnionType([Instance(user_info, []), Instance(anonymous_user_info, [])]) def check_querydict_is_mutable(ctx: MethodContext, django_context: DjangoContext) -> MypyType: diff --git a/mypy_django_plugin/transformers/settings.py b/mypy_django_plugin/transformers/settings.py index f821b971e..bf615723c 100644 --- a/mypy_django_plugin/transformers/settings.py +++ b/mypy_django_plugin/transformers/settings.py @@ -1,25 +1,54 @@ -from mypy.nodes import MemberExpr -from mypy.plugin import AttributeContext, FunctionContext +from typing import Union + +from mypy.nodes import GDEF, MemberExpr, PlaceholderNode, SymbolTableNode, TypeInfo, Var +from mypy.plugin import AttributeContext, DynamicClassDefContext, FunctionContext from mypy.types import AnyType, Instance, TypeOfAny, TypeType from mypy.types import Type as MypyType from mypy_django_plugin.config import DjangoPluginConfig from mypy_django_plugin.django.context import DjangoContext -from mypy_django_plugin.lib import helpers +from mypy_django_plugin.lib import fullnames, helpers def get_user_model_hook(ctx: FunctionContext, django_context: DjangoContext) -> MypyType: auth_user_model = django_context.settings.AUTH_USER_MODEL - model_cls = django_context.apps_registry.get_model(auth_user_model) - model_cls_fullname = helpers.get_class_fullname(model_cls) + + model_info = None + try: + model_cls = django_context.apps_registry.get_model(auth_user_model) + model_cls_fullname = helpers.get_class_fullname(model_cls) + except LookupError: + model_cls_fullname = fullnames.ABSTRACT_BASE_USER_MODEL_FULLNAME model_info = helpers.lookup_fully_qualified_typeinfo(helpers.get_typechecker_api(ctx), model_cls_fullname) + if model_info is None: return AnyType(TypeOfAny.unannotated) return TypeType(Instance(model_info, [])) +def transform_get_user_model_hook(ctx: DynamicClassDefContext, django_context: DjangoContext) -> None: + auth_user_model = django_context.settings.AUTH_USER_MODEL + try: + model_cls = django_context.apps_registry.get_model(auth_user_model) + model_cls_fullname = helpers.get_class_fullname(model_cls) + except LookupError: + model_cls_fullname = fullnames.ABSTRACT_BASE_USER_MODEL_FULLNAME + + node: Union[TypeInfo, PlaceholderNode, Var, None] + node = helpers.lookup_fully_qualified_typeinfo(helpers.get_semanal_api(ctx), model_cls_fullname) + if node is None: + if not ctx.api.final_iteration: + # Temporarily replace the node with a PlaceholderNode. This suppresses + # 'Variable "..." is not valid as a type' errors from mypy + node = PlaceholderNode(model_cls_fullname, ctx.call, ctx.call.line) + else: + node = Var(ctx.name, AnyType(TypeOfAny.from_error)) + + ctx.api.add_symbol_table_node(ctx.name, SymbolTableNode(GDEF, node)) + + def get_type_of_settings_attribute( ctx: AttributeContext, django_context: DjangoContext, plugin_config: DjangoPluginConfig ) -> MypyType: diff --git a/tests/typecheck/contrib/auth/test_decorators.yml b/tests/typecheck/contrib/auth/test_decorators.yml index e1a29c5b5..ddaed3e32 100644 --- a/tests/typecheck/contrib/auth/test_decorators.yml +++ b/tests/typecheck/contrib/auth/test_decorators.yml @@ -41,6 +41,28 @@ from django.contrib.auth.decorators import user_passes_test @user_passes_test # E: Argument 1 to "user_passes_test" has incompatible type "Callable[[HttpRequest], HttpResponse]"; expected "Callable[[Union[AbstractBaseUser, AnonymousUser]], bool]" def view_func(request: HttpRequest) -> HttpResponse: ... +- case: user_passes_test_has_user_of_type_auth_user_model + disable_cache: true + main: | + from typing import Union + from django.http import HttpRequest, HttpResponse + from django.contrib.auth.models import AnonymousUser + from django.contrib.auth.decorators import user_passes_test + from myapp.models import MyUser + + def check_user(user: Union[MyUser, AnonymousUser]) -> bool: return True + @user_passes_test(check_user) + def view_func(request: HttpRequest) -> HttpResponse: ... + custom_settings: | + INSTALLED_APPS = ('django.contrib.contenttypes', 'django.contrib.auth', 'myapp') + AUTH_USER_MODEL='myapp.MyUser' + files: + - path: myapp/__init__.py + - path: myapp/models.py + content: | + from django.db import models + class MyUser(models.Model): + pass - case: permission_required main: | from django.contrib.auth.decorators import permission_required