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

Use MiddlewareType as a type hint for middlewares #1987

Closed
wants to merge 5 commits into from

Conversation

Viicos
Copy link
Contributor

@Viicos Viicos commented Dec 20, 2022

This might brake things in other repositories (fastapi for instance), but this improves type safety in my opinion.

@Kludex
Copy link
Sponsor Member

Kludex commented Dec 20, 2022

I think this was already discussed before. 🤔

@Viicos
Copy link
Contributor Author

Viicos commented Dec 20, 2022

I think this was already discussed before. 🤔

I've tried looking in the already existing issues/PRs/discussions but couldn't find anything 😕

@Kludex
Copy link
Sponsor Member

Kludex commented Dec 20, 2022

EDIT: I think my comment says that we should be doing what you are trying to improve, but that PR was doing too many things... 👀

EDIT 2: This doesn't mean that I agree with those changes, I didn't even check them yet... jfyk

@Viicos
Copy link
Contributor Author

Viicos commented Dec 20, 2022

Note to self: import Protocol for typing_extensions to support 3.7

@Viicos
Copy link
Contributor Author

Viicos commented Dec 23, 2022

Waiting for directives regarding the coverage fail.

@Kludex
Copy link
Sponsor Member

Kludex commented Dec 25, 2022

This might brake things in other repositories (fastapi for instance), but this improves type safety in my opinion.

What does this break on FastAPI?

@alex-oleshkevich
Copy link
Member

Middlewares are just ASGI applications. ASGIApp type hint works for them as well.

@Viicos
Copy link
Contributor Author

Viicos commented Dec 25, 2022

This might brake things in other repositories (fastapi for instance), but this improves type safety in my opinion.

What does this break on FastAPI?

Sorry I wasn't clear on that point, it doesn't break anything for projects that depend on starlette, but it might break type checking if one is passing an unknown type to add_middleware

Middlewares are just ASGI applications. ASGIApp type hint works for them as well.

True indeed, I went for the Protocol definition but it looks like using Callable is equivalent (and doesn't require typing_extensions). Having an explicit type for middlewares improve readability though, so maybe we could use something like:

Middleware: TypeAlias = ASGIApp

This is up to you

@Kludex Kludex requested a review from adriangb January 23, 2023 09:50
@Viicos Viicos changed the title Use typing.Protocol for middlewares Use MiddewareType for middlewares Jan 23, 2023
Kludex
Kludex previously requested changes Feb 4, 2023

ASGIApp: TypeAlias = typing.Callable[[Scope, Receive, Send], typing.Awaitable[None]]

MiddewareType: TypeAlias = ASGIApp
Copy link
Sponsor Member

Choose a reason for hiding this comment

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

Is TypeAlias needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

See https://peps.python.org/pep-0613/. Here it does not add value, as it would be implicitly determined as a TypeAlias any way, but as typing_extensions is used in this project I figured why not use it, that way we know explicitly it is a TypeAlias.

starlette/types.py Outdated Show resolved Hide resolved
starlette/types.py Outdated Show resolved Hide resolved
@Kludex
Copy link
Sponsor Member

Kludex commented Feb 4, 2023

Should we just use ASGIApp instead of creating this new type? 🤔

@Viicos
Copy link
Contributor Author

Viicos commented Feb 4, 2023

Should we just use ASGIApp instead of creating this new type? 🤔

This is purely to avoid confusion, and to avoid having people thinking they need to pass in a ASGIApp in the Middleware's __init__ constructor, even though as stated by @alex-oleshkevich they are the same 🙂

@Kludex Kludex requested a review from a team February 6, 2023 06:11
@Kludex Kludex added this to the Version 0.25.0 milestone Feb 6, 2023
@alex-oleshkevich
Copy link
Member

alex-oleshkevich commented Feb 10, 2023

@Kludex after thinking about this, I found that it makes sense to have dedicated types for such components. It makes things very specific. It should not affect only middlewares, but any other component we may find important.
MiddlewareType, ContextProcessorType, another other callback type, etc. In the same way, we have types for Receive and Send.

I would not drop this PR but rather initiate a deeper discussion on this topic.

@odiseo0
Copy link

odiseo0 commented Feb 10, 2023

Is MiddewareType the correct name? It has a typo in it, or is intended?


ASGIApp: TypeAlias = typing.Callable[[Scope, Receive, Send], typing.Awaitable[None]]

MiddewareType: TypeAlias = ASGIApp
Copy link
Member

Choose a reason for hiding this comment

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

for cls, options in reversed(middleware):
app = cls(app=app, **options)

Considering this code, should we use

class MiddlewareType(Protocol):
    def __call__(
        self,
        app: ASGIApp,
        **options: Any
    ) -> ASGIApp:
        ...

Copy link
Contributor Author

@Viicos Viicos Feb 11, 2023

Choose a reason for hiding this comment

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

If you look at the __iter__ method of the Middleware class, you will see that it returns a tuple whose first element is the middleware class, which corresponds to the cls variable here

Copy link
Member

Choose a reason for hiding this comment

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

cls is MiddlewareType.

Copy link
Contributor Author

@Viicos Viicos Feb 15, 2023

Choose a reason for hiding this comment

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

Yes indeed sorry, there's some confusion here between the __init__ and the __call__ method. When doing app = cls(app=app, **options), we are actually instantiating the middleware, not using the __call__ method. (That is because cls is Type[MiddlewareType], not MiddlewareType).

Defining the MiddlewareType as a Callable or as a Protocol implementing __call__ is equivalent, as Callable is an ABC implementing __call__

Copy link
Member

Choose a reason for hiding this comment

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

This is a ASGI middleware that can be used in starlette.

class ASGIMiddleware:
    def __init__(app: ASGIApp, p0: str, p1: int) -> None:
        ...
    async def __call__(self, scope, receive, send) -> None:
        ...

This also can be used in starlette.

def asgi_middleware(app, p0: str, p1: int) -> ASGIApp:
    async def _asgi(scope, receive, send) -> None:
        ...
    return _asgi

But this can't be used in starlette. Even though P passes the type check you're giving now.

class P:
    def __init__(self) -> None: ...
    def __call__(self, scope, receive, send) -> None: ...

Copy link
Member

Choose a reason for hiding this comment

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

class MyMiddleware:
    def __init__(self, app: ASGIApp, **options: Any) -> None:
        ...

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        ...

This class passes the following type checks.

class MiddlewareType(Protocol):
    def __call__(
        self,
        app: ASGIApp,
        **options: Any
    ) -> ASGIApp:
        ...

Copy link
Contributor Author

@Viicos Viicos Feb 15, 2023

Choose a reason for hiding this comment

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

It might, but imo this is not the way to go:

  • You are using the __call__ method as a way to define the signature of the __init__ method of a middleware, which is confusing
  • the MiddlewareType you created does not have any signature for the real async __call__ method that should be defined on a middleware class

Copy link
Member

Choose a reason for hiding this comment

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

If you don't know how to use typing.Protocol, please consider using mypy to give it a try. 😊

Copy link
Contributor Author

@Viicos Viicos Feb 16, 2023

Choose a reason for hiding this comment

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

I know how Protocols work, but you haven't explained why you are using these parameters for the __call__ method (see my answers above). As I understand it currently, you are using the __call__ method of your protocol as a way to define the __init__ method of a middleware class, right?

Copy link
Contributor Author

@Viicos Viicos Mar 4, 2023

Choose a reason for hiding this comment

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

Will continue discussion in main thread for clarity. Sorry for the confusion, it seems your suggestion is working, I couldn't get it to work since I was still on mypy==0.991.

@Viicos Viicos changed the title Use MiddewareType for middlewares Use MiddlewareType for middlewares Feb 11, 2023
@Kludex Kludex mentioned this pull request Feb 14, 2023
8 tasks
@Kludex Kludex modified the milestones: Version 0.25.0, Version 0.26.0 Feb 14, 2023
starlette/types.py Outdated Show resolved Hide resolved
@Viicos
Copy link
Contributor Author

Viicos commented Apr 20, 2023

@abersheeran sorry for the delay, this is how it looks now:

class MiddlewareType(Protocol):
    __name__: str

    def __call__(self, *args: typing.Any, **kwargs: typing.Any) -> ASGIApp:  # pragma: no cover
        ...

Unfortunately, I can't enforce the app argument to be there, see the linked mypy issue for more detail. If you have a better solution, do not hesitate

@abersheeran
Copy link
Member

class MiddlewareType(Protocol):
    def __call__(
        self,
        app: ASGIApp,
        **options: Any
    ) -> ASGIApp:
        ...

Be more precise.

@Viicos
Copy link
Contributor Author

Viicos commented Apr 21, 2023

class MiddlewareType(Protocol):
    def __call__(
        self,
        app: ASGIApp,
        **options: Any
    ) -> ASGIApp:
        ...

Be more precise.

This doesn't work, as I said, more details in the linked mypy issue. For instance, it won't match against this one:

class ServerErrorMiddleware:
"""
Handles returning 500 responses when a server error occurs.
If 'debug' is set, then traceback responses will be returned,
otherwise the designated 'handler' will be called.
This middleware class should generally be used to wrap *everything*
else up, so that unhandled exceptions anywhere in the stack
always result in an appropriate 500 response.
"""
def __init__(
self,
app: ASGIApp,
handler: typing.Optional[typing.Callable] = None,
debug: bool = False,
) -> None:

as it would expect ServerErrorMiddleware to have **kwargs in the __init__ method

@abersheeran
Copy link
Member

middleware = (
[Middleware(ServerErrorMiddleware, handler=error_handler, debug=debug)]
+ self.user_middleware
+ [
Middleware(
ExceptionMiddleware, handlers=exception_handlers, debug=debug
)
]
)
app = self.router
for cls, options in reversed(middleware):
app = cls(app=app, **options)

We can modify its definition a bit. Because it is like this when it is used.

@Viicos
Copy link
Contributor Author

Viicos commented Apr 21, 2023

We can modify its definition a bit. Because it is like this when it is used.

Apart from moving handler: typing.Optional[typing.Callable] = None, debug: bool = False to **kwargs, it won't match the MiddlewareType with **options: Any, and I don't think it's convenient to have it this way 🤔

My opinion on this is while the __init__ method is incomplete, and middlewares without an app: ASGIApp argument will pass type checking, we at least make sure the __call__ method is correct (or the returned callable in the case of a nested callable). Plus it makes a step towards supporting #1176, which had a similar solution, apart from the fact that it was missing the __name__ attribute.

@abersheeran
Copy link
Member

My meaning is to modify the code to

    def __init__( 
         self, 
         app: ASGIApp, 
         *,
         handler: typing.Optional[typing.Callable] = None, 
         debug: bool = False, 
     ) -> None: 

@Kludex
Copy link
Sponsor Member

Kludex commented Apr 28, 2023

I'm not following the discussion, but I guess is @Viicos turn. 👀

@Viicos
Copy link
Contributor Author

Viicos commented Apr 28, 2023

Yes I'll probably take a look this afternoon 👌

@Viicos
Copy link
Contributor Author

Viicos commented Apr 28, 2023

Unfortunately this doesn't seem to work either:
https://mypy-play.net/?mypy=master&python=3.11&gist=0f955a8ee146e68a1410d7bd9167726d

main.py:20: note: Revealed type is "def (builtins.str)"
main.py:22: note: Revealed type is "None"
main.py:24: error: Argument 1 to "test" has incompatible type "type[ExampleMiddleware]"; expected "MiddlewareType"  [arg-type]
main.py:24: note: Following member(s) of "ExampleMiddleware" have conflicts:
main.py:24: note:     Expected:
main.py:24: note:         def __call__(app: int, **options: Any) -> Callable[[str], None]
main.py:24: note:     Got:
main.py:24: note:         def __init__(app: int, *, other_arg: int | None = ...) -> ExampleMiddleware

But even if it did, I'm not sure having people defining their own middlewares to explicitly use the * argument is good idea 🤔

@abersheeran
Copy link
Member

Unfortunately this doesn't seem to work either: https://mypy-play.net/?mypy=master&python=3.11&gist=0f955a8ee146e68a1410d7bd9167726d

main.py:20: note: Revealed type is "def (builtins.str)"
main.py:22: note: Revealed type is "None"
main.py:24: error: Argument 1 to "test" has incompatible type "type[ExampleMiddleware]"; expected "MiddlewareType"  [arg-type]
main.py:24: note: Following member(s) of "ExampleMiddleware" have conflicts:
main.py:24: note:     Expected:
main.py:24: note:         def __call__(app: int, **options: Any) -> Callable[[str], None]
main.py:24: note:     Got:
main.py:24: note:         def __init__(app: int, *, other_arg: int | None = ...) -> ExampleMiddleware

But even if it did, I'm not sure having people defining their own middlewares to explicitly use the * argument is good idea 🤔

😯 The results of mypy are a bit beyond my imagination.

Copy link
Member

@abersheeran abersheeran left a comment

Choose a reason for hiding this comment

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

Limited by the inference ability of mypy, I temporarily agree with the solution given by this PR. 🙏 Thank you all for your patient communication.

@Viicos
Copy link
Contributor Author

Viicos commented Apr 28, 2023

Thanks for the reviews @abersheeran and sorry for taking some time to respond. Typing callables can sometimes be a pain, but hopefully with ParamSpec and Concatenate this will be easier in the future

@Viicos Viicos changed the title Use ASGIApp as a type hint for middlewares Use MiddlewareType as a type hint for middlewares Apr 28, 2023
@Kludex Kludex added the hold Don't merge it label Apr 30, 2023
@Kludex
Copy link
Sponsor Member

Kludex commented Apr 30, 2023

Pyright doesn't seem to like the proposed changes from this PR. 🤔

@abersheeran
Copy link
Member

Pyright doesn't seem to like the proposed changes from this PR. 🤔

Are there plans to add pyright to CI?

@Viicos
Copy link
Contributor Author

Viicos commented May 2, 2023

Pyright doesn't seem to like the proposed changes from this PR. 🤔

Pyright doesn't seem to support *args: Any, **kwargs: Any treated as any callable (mypy has it with python/mypy#5876). We could switch to MiddlewareType = Callable[..., ASGIApp], but then loose the __name__ attribute that is required by starlette 😕

@Kludex
Copy link
Sponsor Member

Kludex commented May 4, 2023

Pyright doesn't seem to like the proposed changes from this PR. 🤔

Are there plans to add pyright to CI?

No, but since httpx is now using mypy strict, we can follow it and do the same here.

@Viicos
Copy link
Contributor Author

Viicos commented May 16, 2023

Pyright doesn't seem to support *args: Any, **kwargs: Any treated as any callable (mypy has it with python/mypy#5876). We could switch to MiddlewareType = Callable[..., ASGIApp], but then loose the __name__ attribute that is required by starlette 😕

The other alternative would be to switch back to what I did at first:

class MiddlewareType(Protocol):
    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        ...

This is equivalent to ASGIApp, but needs to be defined as a Protocol and not a Callable, otherwise Type[] couldn't be used.

We can also keep things as is if we don't want to support pyright yet.

@Kludex Kludex mentioned this pull request Jun 4, 2023
@Kludex
Copy link
Sponsor Member

Kludex commented Jun 4, 2023

We can also keep things as is if we don't want to support pyright yet.

Let's first do what httpx did, and use mypy on strict mode. Then we can see what errors we have with pyright.

For the time being, I'll close this PR.

Thanks for the patience @Viicos

@Kludex Kludex closed this Jun 4, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
hold Don't merge it typing Type annotations or mypy issues
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

8 participants