-
-
Notifications
You must be signed in to change notification settings - Fork 446
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
Treat get_user_model as a dynamic class factory #1730
Treat get_user_model as a dynamic class factory #1730
Conversation
This treats the get_user_model function as a dynamic class factory in the Django plugin, which means that we can use it for type stubs etc and get the actually configured user model as a type instead of using AbstractBaseUser Note that I couldn't update the last two modules in django.contrib.auth as that caused some kind of circular reference that mypy couldn't deal with.
@@ -7,12 +7,12 @@ from django.db.models.base import Model | |||
from django.http.request import HttpRequest | |||
from typing_extensions import TypeAlias | |||
|
|||
UserModel = get_user_model() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I get stubtest errors if I leave this as it. Not entirely sure why. See the CI failures here: https://github.com/typeddjango/django-stubs/actions/runs/6267951533/job/17022010362
This type does exist at runtime though, but I'm not sure if we should expose it or not? Pretty sure it's not intended to be public API 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If it exist at runtime we can start out by exposing it, regardless of public API or not I'd say
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It exists at runtime, but the types seem to be different, so subtest complains. Is there a way to ignore things from stubtest? Is that what the allowlist is for?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes precisely. Update the allowlist, that's what it's for
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But before we do, can you inspect what the runtime type is?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is what subtest says at least:
error: django.contrib.auth.views.UserModel.username variable differs from runtime type django.db.models.query_utils.DeferredAttribute
Stub: in file /home/runner/work/django-stubs/django-stubs/django-stubs/contrib/auth/views.pyi:76
django.db.models.fields.CharField[Union[builtins.str, builtins.int, django.db.models.expressions.Combinable], builtins.str]
Runtime:
<django.db.models.query_utils.DeferredAttribute object at 0x7f3b7d36c850>
Seems to be the same for all the attributes
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Aha, interesting, that's a descriptor: https://github.com/django/django/blob/4de31ec680df062e5964b630f1b881ead5004e15/django/db/models/query_utils.py#L178
Should be fine to type as "what it really is"
django-stubs/contrib/auth/views.pyi
Outdated
@@ -7,7 +7,7 @@ from django.http.response import HttpResponse, HttpResponseRedirect | |||
from django.views.generic.base import TemplateView | |||
from django.views.generic.edit import FormView | |||
|
|||
UserModel = get_user_model() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same as the comment above, this exists at runtime and causes issues with stubtest
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work! Had a couple of thoughts
_AnyUser: TypeAlias = AbstractBaseUser | AnonymousUser | ||
|
||
UserModel: Any | ||
_UserModel = get_user_model() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we declare _UserModel
in 1 file and import it in the others? Or does _UserModel
exist runtime?
Then we should probably keep it in contrib.auth
next to definition of get_user_model
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Like this:
from django.contrib.auth import _UserModel as _UserModel
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_UserModel
does not exist at runtime, but in some modules UserModel
does exist, for example in django/contrib/auth/backens.py. The subtest complains about these though, saying the typing doesn't match runtime for some attributes
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I also think we should import from one place it and not define for each module.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I gave this approach a go now, but for some reason it ends up causing an endless defer look, which crashes mypy. Can we leave it like this for now and maybe revisit? It does kinda seem like a bug in mypy, cause we never get a call with final_iteration=True
before it gives up, but I'm not entirely sure
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven't run the whole suite just yet, but I tried this diff:
# for `get_user_model()`
- if self.django_context.settings and any(
- i.id == "django.contrib.auth" and any(name == "get_user_model" for name, _ in i.names)
- for i in file.imports
- if isinstance(i, ImportFrom)
- ):
+ if file.fullname == "django.contrib.auth" and self.django_context.settings:
auth_user_model_name = self.django_context.settings.AUTH_USER_MODEL
...
Together with these changes:
-from django.contrib.auth import get_user_model
+from django.contrib.auth import _UserModel
...
-_UserModel = get_user_model()
And an additional test case that depends on django.contrib.auth
to declare a custom user, while also testing that the custom user is resolved via get_user_model
by itself.
- case: test_user_model_resolved_via_get_user_model
main: |
from typing import Union
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.http import HttpRequest, HttpResponse
from django.contrib.auth.decorators import user_passes_test
User = get_user_model()
reveal_type(User)
def check(user: Union[User, AnonymousUser]) -> bool: return True
@user_passes_test(check)
def view(request: HttpRequest) -> HttpResponse:
reveal_type(request.user)
return HttpResponse()
out: |
main:7: note: Revealed type is "def (*args: Any, **kwargs: Any) -> myapp.models.MyUser"
main:11: note: Revealed type is "Union[myapp.models.MyUser, django.contrib.auth.models.AnonymousUser]"
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.contrib.auth.models import AbstractUser, PermissionsMixin
class MyUser(AbstractUser, PermissionsMixin):
...
I get both your test and the one above passing with these changes
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've seen things crash a bit randomly, but I'll try without the requests one. I though mypy was supposed to handle cyclic references though, but maybe not at this level?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you try this test:
pytest tests/typecheck/test_shortcuts.yml::get_user_model_returns_proper_class
It's one of the ones triggering the cyclic dependencies problem here. If fails even if I remove all references to auth
in requests.pyi
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm, I don't hit any errors when trying it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alright, so my issue is that I'm checking if django.contrib.auth
is installed, and if it's not I don't add the dependencies. Guess I'll have to properly figure out what do when that isn't installed. I believe it'll raise an error at runtime 🤔
mypy_django_plugin/main.py
Outdated
if self.django_context.settings and any( | ||
i.id == "django.contrib.auth" and any(name == "get_user_model" for name, _ in i.names) | ||
for i in file.imports | ||
if isinstance(i, ImportFrom) | ||
): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't this a regular import dependency, on django.contrib.auth
? Which mypy should establish by itself
At least I was thinking this hook is for "invisible" dependencies
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This adds a dependency on the configured user model, which might not be in django.contrib.auth
. I guess one option we could do is to add the user model as a dependency of django.contrib.auth
, that might be cleaner?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[...] I guess one option we could do is to add the user model as a dependency of
django.contrib.auth
, that might be cleaner?
Yes, that reads exactly how I imagine this works
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For some reason, doing it like that causes the same issue as above where mypy gets stuck in an infinite loop
Edit: I see now that my issue is that django.contrib.auth
is not registered as installed in the tests for some reason, so the dependency hook doesn't run
mypy_django_plugin/main.py
Outdated
if fullname == fullnames.GET_USER_MODEL_FULLNAME: | ||
return partial(settings.transform_get_user_model_hook, django_context=self.django_context) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we add branching on a helper that checks if django.contrib.auth
is installed?
if fullname == fullnames.GET_USER_MODEL_FULLNAME: | |
return partial(settings.transform_get_user_model_hook, django_context=self.django_context) | |
if fullname == fullnames.GET_USER_MODEL_FULLNAME and self.django_context.is_contrib_auth_installed: | |
return partial(settings.transform_get_user_model_hook, django_context=self.django_context) |
That would help in the dependency hook as well
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was intending to fix this in a separate PR. I think it's better to change the return type to NoReturn
when auth is not configured. That's give a somewhat sensible error message, rather than Variable "User" is not valid as a type
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alright, that's totally fair, we can definitely do it later on
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note: Doing this would also assist in resolving #1380. We could include a test to resolve, looking like below, which currently crashes the plugin:
- case: test_xyz
main: |
from django.contrib.auth import get_user_model
User = get_user_model()
installed_apps:
- myapp
files:
- path: myapp/__init__.py
- path: myapp/models.py
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 |
There was a problem hiding this comment.
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:
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
@@ -7,12 +7,12 @@ from django.db.models.base import Model | |||
from django.http.request import HttpRequest | |||
from typing_extensions import TypeAlias | |||
|
|||
UserModel = get_user_model() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If it exist at runtime we can start out by exposing it, regardless of public API or not I'd say
@@ -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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 😇
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: ... |
There was a problem hiding this comment.
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
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/
model_info: Union[TypeInfo, PlaceholderNode, None] | ||
model_info = helpers.lookup_fully_qualified_typeinfo(helpers.get_semanal_api(ctx), model_cls_fullname) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
model_info: Union[TypeInfo, PlaceholderNode, None] | |
model_info = helpers.lookup_fully_qualified_typeinfo(helpers.get_semanal_api(ctx), model_cls_fullname) | |
model_info: Union[TypeInfo, PlaceholderNode, None] = helpers.lookup_fully_qualified_typeinfo(helpers.get_semanal_api(ctx), model_cls_fullname) |
No stress, but could you try out what I mentioned here: #1730 (comment) whenever you get the chance? |
Hmm, damnit, this ends up with |
Could it be the introduced default dependency:
Can't we skip adding a dependency and try to fallback to https://github.com/django/django/blob/main/django/contrib/auth/__init__.py#L183-L197 |
It's not that, it seems to be something in the |
Im really confused here. It seems like the issue is a forms file where we do something along these lines:
It works fine if I replace |
Hm, is it the Does it stop deferring if you replace that? I would guess it might stop quickly since there's nothing else telling it to defer |
No, I've tried to remove the placeholder node, and that made no difference. I put in a defer call when we cannot resolve the correct type, but when I get the problem it seems like it resolves the correct type long before it crashes 🤔 |
Hm, perhaps we could start out with being a bit more defensive and avoid inserting a node every time? Only do it if we need to |
I've tried that too! What I'm currently testing is 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:
return
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
ctx.api.defer()
return
module = ctx.api.modules[ctx.api.cur_mod_id]
if node == module.names.get(ctx.name):
return
ctx.api.add_symbol_table_node(ctx.name, SymbolTableNode(GDEF, node)) Edit: It seems like the problem is triggered as soon as I insert the symbol table node, even if I never create a placeholder nor defer. It also works if I defer, but don't insert the symbol table node. Really don't understand what's going on here to be honest. |
Just realised one thing we've missed here. Does passing - ctx.api.add_symbol_table_node(ctx.name, SymbolTableNode(GDEF, node))
+ ctx.api.add_symbol_table_node(ctx.name, SymbolTableNode(GDEF, node, plugin_generated=True)) |
That did not seem to make any difference. It works on our real-life project if I only insert the symbol table node on the final iteration, but then some tests start failing. I've been meaning to open a mypy issue, but I haven't gotten around to it yet |
Gotcha, I've seen issues as well in some sort of close by area in #1699, so I agree with what you said earlier; that we're probably threading a thin line here.. |
This treats the get_user_model function as a dynamic class factory in the Django plugin, which means that we can use it for type stubs etc and get the actually configured user model as a type instead of using AbstractBaseUser
Note that I couldn't update the last two modules in
django.contrib.auth
as that caused some kind of circular reference that mypy couldn't deal with.Related issues
user_passes_test
should take inAUTH_USER_MODEL
#1058