Skip to content

Treat get_user_model as a dynamic class factory #1730

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
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
15 changes: 8 additions & 7 deletions django-stubs/contrib/auth/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 8 additions & 9 deletions django-stubs/contrib/auth/backends.pyi
Original file line number Diff line number Diff line change
@@ -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]: ...
Expand All @@ -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(
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions django-stubs/contrib/auth/decorators.pyi
Original file line number Diff line number Diff line change
@@ -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]: ...
Expand Down
4 changes: 2 additions & 2 deletions django-stubs/contrib/auth/middleware.pyi
Original file line number Diff line number Diff line change
@@ -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: ...
Expand Down
5 changes: 1 addition & 4 deletions django-stubs/contrib/auth/password_validation.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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: ...
Expand Down
10 changes: 5 additions & 5 deletions django-stubs/contrib/auth/tokens.pyi
Original file line number Diff line number Diff line change
@@ -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: ...

Expand Down
6 changes: 2 additions & 4 deletions django-stubs/contrib/auth/views.pyi
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions django-stubs/http/request.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions django-stubs/test/client.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]):
Expand Down
2 changes: 2 additions & 0 deletions mypy_django_plugin/lib/fullnames.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
26 changes: 12 additions & 14 deletions mypy_django_plugin/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand Down
35 changes: 2 additions & 33 deletions mypy_django_plugin/transformers/request.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
39 changes: 34 additions & 5 deletions mypy_django_plugin/transformers/settings.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +32 to +37
Copy link
Member

Choose a reason for hiding this comment

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

If we have my lazy reference resolver in #1719 we wouldn't need to use the runtime model here:

Suggested change
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
auth_user_model = django_context.settings.AUTH_USER_MODEL
model_cls_fullname = helpers.resolve_lazy_reference(auth_user_model, ...)
if model_cls_fullname is None:
model_cls_fullname = fullnames.ABSTRACT_BASE_USER_MODEL_FULLNAME

We might want to use the fallback only on last iteration then


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:
Expand Down
22 changes: 22 additions & 0 deletions tests/typecheck/contrib/auth/test_decorators.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

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

Why would we need to disable cache?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops, this is just a copy-past mistake 😇

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: ...
Comment on lines +47 to +55
Copy link
Member

Choose a reason for hiding this comment

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

Can we check a couple of the often used and now plugin changed signatures from django.contrib.auth, e.g. authenticate and login

Suggested change
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: ...
from typing import Union
from django.http import HttpRequest, HttpResponse
from django.contrib.auth import authenticate, login, get_user
from django.contrib.auth.models import AnonymousUser
from django.contrib.auth.decorators import user_passes_test
from myapp.models import MyUser
reveal_type(authenticate) # N: Revealed type is "def (...) -> myapp.models.MyUser | None"
reveal_type(login) # N: Revealed type is "..."
reveal_type(get_user) # N: Revealed type is "..."
def check_user(user: Union[MyUser, AnonymousUser]) -> bool: return True
@user_passes_test(check_user)
def view_func(request: HttpRequest) -> HttpResponse: ...

We could perhaps change the name of the test to something like test_auth_user_model in that case? Also, its "sibling" test is in models/test_contrib_models.yml but I'm totally fine to keep this test under contrib/auth/

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
Expand Down