8000 Add typing test for Websockets exception_handler by kristjanvalur · Pull Request #2042 · encode/starlette · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Add typing test for Websockets exception_handler #2042

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
wants to merge 7 commits into from

Conversation

kristjanvalur
Copy link
Contributor
@kristjanvalur kristjanvalur commented Feb 19, 2023

Version 0.21.0 added support for handing errors in WebSockets.
However, the signature of Starlette(exception_handlers) keyword argument was not updated and there
were no type checks catching the mismatch.

  • We tweak the test_applications.py to get mypy to check types
  • We update the type signature for the exception_handlers keyword.
  • We update the in-line documentation for class Starlette

Internally, the type hints don't match. And they don't even allow for async functions. In light of this, I propose a
different approach, to change the type to Callable[[Union[Request, Websocket], Any], Any]. Using Exception as the second arg in the type strictly precludes us from defining handers with a more specific excepty type argument. And the variable type of the first argument, variable return argument, and allowing both sync and async, well, maybe this is one place where type strictness could be relaxed.

  • Initially raised as discussion #...

@kristjanvalur kristjanvalur force-pushed the kristjan/handler branch 3 times, most recently from fcdbda3 to c542f6d Compare February 19, 2023 22:29
@kristjanvalur kristjanvalur marked this pull request as ready for review February 19, 2023 22:44
@@ -81,7 +81,8 @@ async def websocket_raise_custom(websocket: WebSocket):
raise CustomWSException()


def custom_ws_exception_handler(websocket: WebSocket, exc: CustomWSException):
def custom_ws_exception_handler(websocket: WebSocket, exc: Exception) -> None:
Copy link
Member

Choose a reason for hiding this comment

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

Was there an issue with the typing on exc?

Copy link
Contributor Author
@kristjanvalur kristjanvalur Feb 22, 2023

Choose a reason for hiding this comment

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

The type was not being checked. Once type checking was enabled, it was clear that having the CustomWSException is incorrect (as the type spec on Starlette(exception_handlers=...) specifies that the second argument can be any Exception instance, not just CustomWSException.

Copy link
Member

Choose a reason for hiding this comment

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

what was the mypy issue?

Copy link
Contributor Author
@kristjanvalur kristjanvalur Feb 22, 2023

Choose a reason for hiding this comment

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

Mypy complains that element 4 of the dict literal is the wrong type.

Look, if you specify a callable which takes a certain type as an argument, you cannot provide a callable which expects a narrower type.

This here is invalid typing:

from typing import Callable
def set_callback(callback: Callable[[Exception], None]) -> None:
    pass
    
def my_callback(arg: RuntimeError)->None:
    pass
    
set_callback(my_callback)  # errors

running mypy on this results in:

test.py:8: error: Argument 1 to "set_callback" has incompatible type "Callable[[RuntimeError], None]"; expected "Callable[[Exception], None]"  [arg-type]

Comment on lines 119 to 124
exception_handlers={
500: error_500,
405: method_not_allowed,
HTTPException: http_exception,
CustomWSException: custom_ws_exception_handler,
},
Copy link
Member

Choose a reason for hiding this comment

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

Why is this 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.

I'm no expert on MyPy, and how to make it test types, but without it, the exception_handlers variable was apparently untyped, and mypy didn't complain about the contents of it not matching the specification. Maybe there is a better way tom make mypy be more strict in checking types in the unittests. the --strict mode does exists but that is completely overboard for unittests, IMHO.

By putting the dict in-line, mypy kicks into gear and starts testing to see if the contents of the dict actually matches what the type hints for Starlette() specify.

Copy link
Member

Choose a reason for hiding this comment

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

I don't think that makes sense. How did you prove this assumption?

Copy link
Contributor Author
@kristjanvalur kristjanvalur Feb 22, 2023

Choose a reason for hiding this comment

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

by running scripts/lint.

My guess is the following: MyPy will check that the type of a literal matches the argument list, but it will not automatically and implicitly assign a type to a variable (exception_handers) based on the literal used to initialize it. Since the variable has no type, it won't be checked against the argument list, and mypy isn't in --strict mode and doesn't complain about it.

Anyway, you can clearly see that the type of custom_ws_exception_handler, does not match the hints, (For one thing, it specifies a WebSocket argument where Starlette() expects callbacks taking a Request) yet mypy does not complain about this dict.

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 problem was that some of the exception handlers were untyped (ANY) and then the whole list collapses to that and even your carefully typed exception handlers don't get any testing.

@Kludex Kludex added the typing Type annotations or mypy issues label Feb 22, 2023
@kristjanvalur
Copy link
Contributor Author

I think there are two issues that maybe should be solved independently:

  1. Make sure that type checking is happening in the unit tests so that we detect when we are violating the type specification. Not sure how to do that without --strict paraemter to MyPy (I'm not an expert)
  2. The type specification for the exception_handlers should perhaps be relaxed with more typing.Any, now that the accepted type signatures are so varied that it becomes cumbersome to use.

@kristjanvalur
Copy link
Contributor Author
kristjanvalur commented Feb 24, 2023

Ok, I think I have figured out what is happening, why we are not getting warnings.
By adding a exception_handlers = 1 line, I get the following error from MyPy:

tests/test_applications.py:107: error: Incompatible types in assignment (expression has type "int", variable has type "Dict[object, Callable[[Any, Any], Any]]")  [assignment]

In other words: MyPy has implicitly typed exception_handlers as having Any callables, because with the lowest common denomitator. The callables used for initializing it are untyped.
And a dict, typed that way, is valid as an argument.

By adding type hints to all of the exception handlers, MyPy gets clued in and it fails:

tests/test_applications.py:126: error: Argument "exception_handlers" to "Starlette" has incompatible type "Dict[object, function]"; expected "Optional[Mapping[Any, Callable[[Request, Exception], Union[Response, Awaitable[Response]]]]]"  [arg-type]

I think the best course of action here is to excplicitly define exception handlers as being of type Callable[[Any, Any], Any] and entrust this part of the API to Python's dynamic typing.

@kristjanvalur
Copy link
Contributor Author

This file demonstrates the issue. I guess I'll need to raise an issue over at MyPy.
Despite being strongly typed, the handler2 is accepted as a callable where a different signature is present.

from typing import Callable, Any, Mapping


def api(handlers: Mapping[Any, Callable[[str], int]]) -> None:
    pass


def handler1(arg):
    return 1

def handler2(arg:int) -> str:
    return "foo"


handlers = {
    1: handler1,
    2: handler2
}

api(handlers)

@kristjanvalur
Copy link
Contributor Author

Well, MyPy promptly closed the issue without discussion. I guess that's that then.
python/mypy#14779

My suggestion, then, is to simply make sure that we don't add callables to intermediate dicts, to ensure that the typing information of the individual handlers is actually checked against the spec.

@adriangb
Copy link
Member
adriangb commented Feb 24, 2023

We've kinda been through this before: #1308.

I think the best thing we could do is something like this:

https://mypy-play.net/?mypy=latest&python=3.11&gist=ace307dc705717b1ef1fedc21708b8f3

Which would be a pure addition, nothing inside class Starlette or anywhere else needs to change.

Or we could not inherit from dict and just keep it as a private attribute / implementation detail but that would require supporting two code-paths inside of class Starlette.

@kristjanvalur
Copy link
Contributor Author

We've kinda been through this before: #1308.

I think the best thing we could do is something like this:

Yes, a TypeVar would allow to have typed handlers with their own exception subclasses.
IMHO I think it is overly complex. We should simply allowe untyped handers with the specified number of arguments.
Sometimes dynamic is best.

Comment on lines 52 to 60
typing.Union[
typing.Callable[
[Response, Exception],
typing.Union[Response, typing.Awaitable[Response]],
],
typing.Callable[
[WebSocket, Exception],
typing.Union[None, typing.Awaitable[None]],
],
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 revert everything else in this PR and only maintain this change?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

sure. Except, in stead of Exception here I think what you really want is Any. Otherwise, you cannot define an exception handler taking an exception class other than Exception without type checking complaining.

6D40
@Kludex Kludex added the websocket WebSocket-related label Mar 5, 2023
@PIG208
Copy link
PIG208 commented Jun 6, 2023

This file demonstrates the issue. I guess I'll need to raise an issue over at MyPy. Despite being strongly typed, the handler2 is accepted as a callable where a different signature is present.

This is the expected behavior because mypy infers the common type that unionizes with all the values of the dict for its value. If you don't want permissive type inference, you need to annotate the type of the dict.

@kristjanvalur
Copy link
Contributor Author

This is the expected behavior because mypy infers the common type that unionizes with all the values of the dict for its value. If you don't want permissive type inference, you need to annotate the type of the dict.

Yes. This was explained to me over at MyPy too, basically, an untyped method silently poisons the dict.
It's one of those things which catches people which aren't seasoned in Python type hints unawares. And in the case being
demonstrated here, caused us (and others) to not notice the discrepancy in types.

@Kludex
Copy link
Member
Kludex commented Dec 1, 2023

This is outdated. We have created the ExceptionHandler type.

@Kludex Kludex closed this Dec 1, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
typing Type annotations or mypy issues websocket WebSocket-related
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants
0