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

Improve types of django.contrib.admin.decorators #1267

Merged
merged 1 commit into from
Dec 13, 2022

Conversation

flaeppe
Copy link
Member

@flaeppe flaeppe commented Nov 19, 2022

I have made things!

  • Type annotations of django.contrib.admin.decorators previously changed the signature of the classes/functions that were decorated. Not any more.
  • The decorators now set a bound on the signature of what they support decorating. As such an error will be emitted on an incorrect signature of the decorated function/class.
    • Take note that since a bound is used, annotating subclasses of the bounds are supported.
  • The description= arguments now supports both Promise and str -> _StrPromise (for gettext_lazy support)

Related issues

None

function: Callable[[_ModelAdmin, _Request, _QuerySet], None],
permissions: Sequence[str] | None = ...,
description: _StrPromise | None = ...,
) -> Callable[[_ModelAdmin, _Request, _QuerySet], None]: ...
Copy link
Collaborator

Choose a reason for hiding this comment

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

Would be easier and less repetition to define

_ActionCallable = TypeVar("_ActionCallable", bound=Callable[[ModelAdmin, HttpRequest, QuerySet], None])

Copy link
Member Author

Choose a reason for hiding this comment

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

That's not gonna work. It needs a bound, if you declare them without a TypeVar it's gonna change the signature of the function that is decorated

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh, right, the bound callable won't match either. I started out with that but then you'll need nested TypeVars to get a correct bound. Else we're not supporting subclasses. See here

https://mypy-play.net/?mypy=0.982&python=3.10&gist=de1239d0a4ced1707ec6afb9f4610160

Copy link
Member Author

Choose a reason for hiding this comment

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

It looks cluttered, I agree, but this was the only way I could get it to work correctly

Copy link
Member Author

Choose a reason for hiding this comment

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

If you manage to figure out how to declare a TypeVar(..., bound=Callable[...]) that works.
Pass me a playground link showing how, I'm very interested in knowing how to with mypy (I want to use it in other projects🙂)

Copy link
Member Author

Choose a reason for hiding this comment

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

Copy link
Collaborator

@intgr intgr Nov 21, 2022

Choose a reason for hiding this comment

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

OK I understand now. Ordinarily with bound=Callable[[Model], ...], the type system applies contravariant type compatibility to arguments of the callable: to be type-safe, the callable must accept every possible Model object that could be passed in. (explanation)

What you're trying to do here is covariant behavior: the callable should accept some subclass of Model as argument. A TypeVar is bound checking is covariant. Which is the correct subclass in the particular instance won't be type-checked.

It's a bit of a hack and not entirely type-safe, but I think practicality beats purity.

Copy link
Member Author

Choose a reason for hiding this comment

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

[...] Which is the correct subclass in the particular instance won't be type-checked.

It's a bit of a hack and not entirely type-safe, but I think practicality beats purity.

Could you elaborate on what it is that won't be type checked and why it's not type safe? (I presume with the current display annotations func: Callable[[_ModelT], _T])

I'm not sure I'm following on how that conclusion is reached.

Copy link
Collaborator

Choose a reason for hiding this comment

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

For example given two models Foo and Bar. With your proposed solution, you may have a FooAdmin class with a @display method def my_display_func(obj: Bar). The type checker has no idea that Bar is incorrect and the method will actually accept Foo instances. That's what I meant by "not type-safe"

The "type-safe" approach is contravariant compatibility: you can define my_display_func(obj: Model) or my_display_func(obj: object), and it's assured that any passed argument is always an instance of Model or object. It's type-safe, but useless: almost every display callback needs to access fields of the particular Model subclass, you'd need to use cast() or isinstance() guards to do most useful things.

Copy link
Member Author

Choose a reason for hiding this comment

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

Hm yeah that's true. Didn't think of that.

I suppose in order to get around that, it would be necessary for admin.display to have the context of FooAdmin (e.g. FooAdmin.model), although it's a naive, dynamic, decorator that doesn't even consider the ModelAdmin.

What I'm saying is e.g. @admin.display[MyModelAdmin] (if ModelAdmin accepted arguments runtime) and pick up its model. But then it also has to be possible to pick up the Model argument for ModelAdmin

django-stubs/contrib/admin/decorators.pyi Show resolved Hide resolved
_T = TypeVar("_T")
_Model = TypeVar("_Model", bound=Model)
_ModelAdmin = TypeVar("_ModelAdmin", bound=ModelAdmin)
_Request = TypeVar("_Request", bound=HttpRequest)
Copy link
Collaborator

Choose a reason for hiding this comment

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

The only remaining dilemma I have is whether the request argument should be a TypeVar, or change it to just HttpRequest without TypeVar.

It seems most django-stubs code already assumes that requests are always HttpRequest and that tends to be sufficient.

Technically most of the time they're WSGIRequest, but that seems to be an implementation detail. There is no explicit documentation about WSGIRequest in Django docs (barring testing topics and release notes). And ASGIRequest is now now rearing its head.

Unless someone else chimes in, I'll trust your judgment here.

Copy link
Member Author

Choose a reason for hiding this comment

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

While the type-unsafety you've already stated still stands. Having this, it should be possible for someone to do

@action(...)
def my_action(..., request: MyCustomHttpRequest, ...) -> None:
    request.attribute_set_by_middleware

e.g. Capturing attributes attached by middleware(s) or so.

Or doing stuff mentioned in docs: https://github.com/typeddjango/django-stubs#how-can-i-create-a-httprequest-thats-guaranteed-to-have-an-authenticated-user

@intgr
Copy link
Collaborator

intgr commented Nov 23, 2022

Sorry to delay this for so long.

Since this change is fairly complex, I'd like to see a test case for these as well. Look in tests/typecheck/ for examples. A single ModelAdmin class with methods where each decorator is used with @bare as well as @with(args) calling style would be great.

With that, I'm happy to approve this.

@intgr
Copy link
Collaborator

intgr commented Dec 13, 2022

Thanks! I played around with this and created some test cases.

I'm going to merge this and open another PR with the tests and some additional tweaks.

@flaeppe
Copy link
Member Author

flaeppe commented Dec 13, 2022

Sorry for being so slow, I've had a couple of test cases locally since I built this. Just never came around to pushing it

@flaeppe flaeppe deleted the fix/admin-decorators branch December 13, 2022 17:55
@intgr
Copy link
Collaborator

intgr commented Dec 13, 2022

Sorry if I was too impatient. Maybe some advance heads-up would have been warranted.

If you don't mind, have a look at #1292 and see if I missed anything.

@flaeppe
Copy link
Member Author

flaeppe commented Dec 13, 2022

Not to worry, it's me being slow. I'll try to have a look tomorrow

intgr added a commit that referenced this pull request Dec 15, 2022
Some follow-up additions on top of #1267:

* Added tests
* The `@display` decorator now supports usage for `ModelAdmin` methods as well.
* Changed `_StrPromise` to `_StrOrPromise` where plain `str` should be supported as well.
* `ModelAdmin.display` didn't previously allow functions returning booleans.
* `ModelAdmin.actions` didn't previously allow having accurate type hints of the passed function (only allowed base class `ModelAdmin`)
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.

2 participants