Skip to content
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

Transform user_passes_test signature #1229

Closed

Conversation

ljodal
Copy link
Contributor

@ljodal ljodal commented Nov 3, 2022

Add a transformer that updates the signature of user_passes_test to reflect settings.AUTH_USER_MODEL.

Related issues

Fixes #1058

Add a transformer that updates the signature of user_passes_test to
reflect settings.AUTH_USER_MODEL.

Fixes typeddjango#1058
from users.models import User
def check_user(user: Union[User, AnonymousUser]) -> bool: ...
@user_passes_test(check_user)
def view_func(request: HttpRequest) -> HttpResponse: ...
Copy link
Member

Choose a reason for hiding this comment

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

You need to assert types here, current case is not enough.

What do you expect to happen?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Without the changes in the PR mypy will produce a type error on line 46 (because check_user doesn't match Callable[[AbstratBaseUser|AnonymousUser], bool]), so the test fails. With this PR the signature of user_passes_test is changed so there's no type errors and thus the test passes.

from django.contrib.auth.models import AnonymousUser
from django.http import HttpRequest, HttpResponse
from users.models import User
def check_user(user: Union[User, AnonymousUser]) -> bool: ...
Copy link
Member

Choose a reason for hiding this comment

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

What about def check_user2(user: User) -> bool: ... ?

Copy link
Member

Choose a reason for hiding this comment

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

And def check_user(user: Union[ AnonymousUser, User]) -> bool: ...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The former would be an error, because it doesn't accept AnonymousUser, while the latter is perfectly valid. Note that this change only changes the signature of user_passes_test, all the type checking is done by mypy. So basically the signature is changed from:

def user_passes_test(test_func: Callable[[AbstractBaseUser, AnonymousUser], bool]], ...):

to:

def user_passes_test(test_func: Callable[[settings.AUTH_USER_MODEL, AnonymousUser], bool]], ...):

So anything valid matching the first signature would still match the second one, but the second one allows writing more specific types (using your configured user model).

I added a few more tests cases to demonstrate this.

@intgr
Copy link
Collaborator

intgr commented Nov 4, 2022

This seems like significant complexity to dynamically update the signature of just one function.

There are lots more places in Django that depend on AUTH_USER_MODEL. Are you planning to extend this and add update the other places as well, and can this be implemented generically?

request.user seems like an important one for example.

@ljodal
Copy link
Contributor Author

ljodal commented Nov 4, 2022

This seems like significant complexity to dynamically update the signature of just one function.

There are lots more places in Django that depend on AUTH_USER_MODEL. Are you planning to extend this and add update the other places as well, and can this be implemented generically?

request.user seems like an important one for example.

This is already done for request.user:

def set_auth_user_model_as_type_for_request_user(ctx: AttributeContext, django_context: DjangoContext) -> MypyType:

Same with get_user_model():

def get_user_model_hook(ctx: FunctionContext, django_context: DjangoContext) -> MypyType:

I'm not sure if there's an easy way to do this generically (I'm also unsure how many places this is needed). I've just fixed the only place where this was causing new type issues in our codebase

@sobolevn
Copy link
Member

sobolevn commented Nov 4, 2022

I think instead we can make AUTH_USER_MODEL an actual type and use it everywhere 🤔

@ljodal
Copy link
Contributor Author

ljodal commented Nov 7, 2022

I'm not exactly sure if I'm following how you'd envision that working? Altering the type of settings.AUTH_USER_MODEL to be a type alias? Wouldn't that cause issues when directly accessing that setting and expecting a string?

I'm happy to do some cleanup here and add a helper to extract the 'current' user type (to reduce duplication), but I don't see how this could be done in another way.

@sobolevn
Copy link
Member

sobolevn commented Nov 7, 2022

I think we can at least experiment to do something like this:

  1. Import settings.AUTH_USER_MODEL model from runtime
  2. Pass it through our regular plugin machinery
  3. Redefine settings.AUTH_USER_MODEL to be an alias for that type

So, ideally we can change all (most) User references in stubs with AUTH_USER_MODEL type. Like this:

def user_passes_test(func: Callable[[AUTH_USER_MODEL | AnonymousUser], bool]) -> ...: ...`

However, I don't say that:

  1. It is a good idea, I am not completely sure how it can be implemented
  2. That it will work at all

@ljodal
Copy link
Contributor Author

ljodal commented Nov 7, 2022

One immediate problem I see with that is that settings.USER_AUTH_MODEL should stay defined as a string, as someone might rely on that at runtime as well. I guess get_user_model() is the API to use to get the actual model. I don't think that would be accepted as a type though. Maybe we could add something like this somewhere, but I'm not sure where? And I'm not sure if it would even work?

User = get_user_model()

@sobolevn
Copy link
Member

sobolevn commented Nov 7, 2022

It can work because of get_dynamic_class_hook(), but I am not sure this will be accepted as a type.

Ok, we can try to populate _USER_AUTH_MODEL magic thing 🤔

@ljodal
Copy link
Contributor Author

ljodal commented Nov 7, 2022

Hmm, wouldn't that break usage of the stubs without the plugin? For this to work properly I'd think it has to be defined somewhere in the stubs at least?

Copy link
Member

@flaeppe flaeppe left a comment

Choose a reason for hiding this comment

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

I added a few comment suggestions to transform_user_passes_test.

Feel free to adjust them as you find most appropriate. All I'm finding important is to have some comments there to make intention and what is happening a bit more obvious.

Comment on lines +28 to +30
if not test_func_type.arg_types or not isinstance(test_func_type.arg_types[0], UnionType):
return ctx.default_signature
union = test_func_type.arg_types[0]
Copy link
Member

Choose a reason for hiding this comment

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

It's not obvious why we're extracting a Union here. We should add a comment displaying why (context of us expecting AbstractBaseUser | AnonymousUser isn't anywhere near here)

Suggested change
if not test_func_type.arg_types or not isinstance(test_func_type.arg_types[0], UnionType):
return ctx.default_signature
union = test_func_type.arg_types[0]
# Extract the Union argument for user out of the test function:
# user: AbstractBaseUser | AnonymousUser
if not test_func_type.arg_types or not isinstance(test_func_type.arg_types[0], UnionType):
return ctx.default_signature
union = test_func_type.arg_types[0]

Comment on lines +31 to +32

new_union = UnionType([Instance(user_model_info, [])] + union.items[1:])
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
new_union = UnionType([Instance(user_model_info, [])] + union.items[1:])
# Replace AbstractBaseUser with the model type AUTH_USER_MODEL points to
# user: <AUTH_USER_MODEL> | AnonymousUser
new_union = UnionType([Instance(user_model_info, [])] + union.items[1:])

Comment on lines +33 to +36

new_test_func_type = test_func_type.copy_modified(
arg_types=[new_union] + test_func_type.arg_types[1:],
)
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
new_test_func_type = test_func_type.copy_modified(
arg_types=[new_union] + test_func_type.arg_types[1:],
)
# Update the test function with the adjusted user annotation
new_test_func_type = test_func_type.copy_modified(
arg_types=[new_union] + test_func_type.arg_types[1:],
)

Comment on lines +37 to +40

return ctx.default_signature.copy_modified(
arg_types=[new_test_func_type] + ctx.default_signature.arg_types[1:],
)
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
return ctx.default_signature.copy_modified(
arg_types=[new_test_func_type] + ctx.default_signature.arg_types[1:],
)
# Update user_passes_test function signature to expect the new test function signature as first argument
return ctx.default_signature.copy_modified(
arg_types=[new_test_func_type] + ctx.default_signature.arg_types[1:],
)

from mypy_django_plugin.lib import helpers


def transform_user_passes_test(ctx: FunctionSigContext, django_context: DjangoContext) -> FunctionLike:
Copy link
Member

Choose a reason for hiding this comment

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

Can't we name this function a bit more specifically?

e.g.

Suggested change
def transform_user_passes_test(ctx: FunctionSigContext, django_context: DjangoContext) -> FunctionLike:
def attach_AUTH_USER_MODEL_to_user_passes_test(ctx: FunctionSigContext, django_context: DjangoContext) -> FunctionLike:

@flaeppe
Copy link
Member

flaeppe commented Sep 19, 2023

It can work because of get_dynamic_class_hook(), but I am not sure this will be accepted as a type.

Ok, we can try to populate _USER_AUTH_MODEL magic thing 🤔

I got a spin on this idea:

settings.AUTH_USER_MODEL is Django utilising its lazy referencing ("<app_label>.<object_name>"), and we also know that it looks for a registered model under the hood. Which means that (probably currently) we've typed or can assume type[Model] wherever settings.AUTH_USER_MODEL has been resolved.

So, what if the stubs add some variable: _AUTH_USER_MODEL = NewType("_AUTH_USER_MODEL", Type[Model])? That might perhaps trigger get_dynamic_class_hook and give us a statically declared type which is "bound" to be correct but the plugin is able to refine it into a even more specific type?

Then the stubs replaces all places where we expect settings.AUTH_USER_MODEL to have been resolved e.g.

- -> Type[Model]:
+ -> _AUTH_USER_MODEL:

Which (hopefully) would align all types.

@sobolevn
Copy link
Member

I like this idea (I think that adding a hook is a must, however - otherwise, we will have too many errors in users' code).

@ljodal
Copy link
Contributor Author

ljodal commented Sep 19, 2023

Hmm, interesting. Sounds like it's worth a try! Maybe best to try in a separate PR though?

@flaeppe
Copy link
Member

flaeppe commented Sep 19, 2023

Yeah a separate PR would be best

@ljodal
Copy link
Contributor Author

ljodal commented Sep 20, 2023

Are we sure we can't just use get_auth_user() together with get_dynamic_class_hook() though? Something like this?

# stubs.pyi

User = get_auth_user()

class HttpRequest:
    user: User | AnonymousUser

@sobolevn
Copy link
Member

I think we should, because it is a perfect use-case for it.

@flaeppe
Copy link
Member

flaeppe commented Sep 20, 2023

I don't think that works. Isn't it only TypeAlias that can be annotated with?

And that function call can't return a type alias, I've tried make the plugin spoof that kind of aliasing as well, in #1699

@flaeppe
Copy link
Member

flaeppe commented Sep 20, 2023

Mypy makes User a variable(Var) and that can't be annotated with

@ljodal
Copy link
Contributor Author

ljodal commented Sep 20, 2023

But can't we change that using get_dynamic_class_hook?

@flaeppe
Copy link
Member

flaeppe commented Sep 20, 2023

But can't we change that using get_dynamic_class_hook?

Probably, I've tried that in #1699 but it isn't trivial

@ljodal
Copy link
Contributor Author

ljodal commented Sep 21, 2023

I think I managed to make it work: #1730

Main trick was replacing the symbol with a placeholder when deferring, as otherwise the variable error popped up (that seems like a bug in mypy)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

user_passes_test should take in AUTH_USER_MODEL
4 participants