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

Turn all MultiError occurrences into BaseExceptionGroup #6

Merged
merged 1 commit into from
Apr 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
.. _multi_errors:
.. _exception_groups:

Multi Errors
============
Exception groups
================

MultiErrors raised during the handling of a request or websocket are
Exception groups raised during the handling of a request or websocket are
caught and the exceptions contianed are checked against the handlers,
the first handled exception will be returned. This may lead to
non-deterministic code in that it will depend on which error is raised
first (in the case that multi errors can be handled).
first (in the case that exception groups can be handled).
2 changes: 1 addition & 1 deletion docs/discussion/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ Discussions
:maxdepth: 1

background_tasks.rst
multi_errors.rst
exception_groups.rst
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ pydata_sphinx_theme = { version = "*", optional = true }
python = ">=3.7"
hypercorn = { version = ">=0.12.0", extras = ["trio"] }
quart = ">=0.18"
trio = ">=0.9.0"
trio = ">=0.19.0"
exceptiongroup = ">=1.0.0"
Comment on lines +68 to +69
Copy link

@Zac-HD Zac-HD Apr 11, 2023

Choose a reason for hiding this comment

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

This change actually requires Trio >= 0.22, which is when it converted over to ExceptionGroup upstream.

I'd also recommend depending on exceptiongroup >= 1.1.1; there are a lot of compatibility and bugfixes in that delta.


[tool.poetry.dev-dependencies]
tox = "*"
Expand Down
26 changes: 10 additions & 16 deletions src/quart_trio/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import Any, Awaitable, Callable, Coroutine, Optional, Union

import trio
from exceptiongroup import BaseExceptionGroup
from hypercorn.config import Config as HyperConfig
from hypercorn.trio import serve
from quart import Quart, request_started, websocket_started
Expand Down Expand Up @@ -133,8 +134,8 @@ async def handle_request(self, request: Request) -> Union[Response, WerkzeugResp
return await self.full_dispatch_request(request_context)
except trio.Cancelled:
raise # Cancelled should be handled by serving code.
except trio.MultiError as error:
filtered_error = trio.MultiError.filter(_keep_cancelled, error)
except BaseExceptionGroup as error:
filtered_error, _ = error.split(trio.Cancelled)
if filtered_error is not None:
raise filtered_error

Expand All @@ -157,14 +158,14 @@ async def full_dispatch_request(
result = await self.preprocess_request(request_context)
if result is None:
result = await self.dispatch_request(request_context)
except (Exception, trio.MultiError) as error:
except (Exception, BaseExceptionGroup) as error:
result = await self.handle_user_exception(error)
return await self.finalize_request(result, request_context)

async def handle_user_exception(
self, error: Union[Exception, trio.MultiError]
self, error: Union[Exception, BaseExceptionGroup]
) -> Union[HTTPException, ResponseReturnValue]:
if isinstance(error, trio.MultiError):
if isinstance(error, BaseExceptionGroup):
for exception in error.exceptions:
try:
return await self.handle_user_exception(exception) # type: ignore
Expand All @@ -183,8 +184,8 @@ async def handle_websocket(
return await self.full_dispatch_websocket(websocket_context)
except trio.Cancelled:
raise # Cancelled should be handled by serving code.
except trio.MultiError as error:
filtered_error = trio.MultiError.filter(_keep_cancelled, error)
except BaseExceptionGroup as error:
filtered_error, _ = error.split(trio.Cancelled)
if filtered_error is not None:
raise filtered_error

Expand All @@ -207,7 +208,7 @@ async def full_dispatch_websocket(
result = await self.preprocess_websocket(websocket_context)
if result is None:
result = await self.dispatch_websocket(websocket_context)
except (Exception, trio.MultiError) as error:
except (Exception, BaseExceptionGroup) as error:
result = await self.handle_user_exception(error)
return await self.finalize_websocket(result, websocket_context)

Expand Down Expand Up @@ -246,7 +247,7 @@ def add_background_task(self, func: Callable, *args: Any, **kwargs: Any) -> None
async def _wrapper() -> None:
try:
await copy_current_app_context(func)(*args, **kwargs)
except (trio.MultiError, Exception) as error:
except (BaseExceptionGroup, Exception) as error:
await self.handle_background_exception(error) # type: ignore

self.nursery.start_soon(_wrapper)
Expand All @@ -262,10 +263,3 @@ async def shutdown(self) -> None:
pass
else:
raise RuntimeError("While serving generator didn't terminate")


def _keep_cancelled(exc: BaseException) -> Optional[trio.Cancelled]:
if isinstance(exc, trio.Cancelled):
return exc
else:
return None
5 changes: 3 additions & 2 deletions src/quart_trio/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import AnyStr, cast, Optional, TYPE_CHECKING

import trio
from exceptiongroup import BaseExceptionGroup
from hypercorn.typing import (
ASGIReceiveCallable,
ASGISendCallable,
Expand Down Expand Up @@ -118,7 +119,7 @@ async def __call__(self, receive: ASGIReceiveCallable, send: ASGISendCallable) -
if event["type"] == "lifespan.startup":
try:
await self.app.startup()
except (Exception, trio.MultiError) as error:
except (Exception, BaseExceptionGroup) as error:
await send(
cast(
LifespanStartupFailedEvent,
Expand All @@ -134,7 +135,7 @@ async def __call__(self, receive: ASGIReceiveCallable, send: ASGISendCallable) -
elif event["type"] == "lifespan.shutdown":
try:
await self.app.shutdown()
except (Exception, trio.MultiError) as error:
except (Exception, BaseExceptionGroup) as error:
await send(
cast(
LifespanShutdownFailedEvent,
Expand Down
18 changes: 11 additions & 7 deletions tests/test_app.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import NoReturn

import pytest
import trio
from exceptiongroup import BaseExceptionGroup
from quart import ResponseReturnValue
from quart.testing import WebsocketResponseError

Expand All @@ -14,17 +14,21 @@ def _error_app() -> QuartTrio:

@app.route("/")
async def index() -> NoReturn:
raise trio.MultiError([ValueError(), trio.MultiError([TypeError(), ValueError()])])
raise BaseExceptionGroup(
"msg1", [ValueError(), BaseExceptionGroup("msg2", [TypeError(), ValueError()])]
)

@app.websocket("/ws/")
async def ws() -> NoReturn:
raise trio.MultiError([ValueError(), trio.MultiError([TypeError(), ValueError()])])
raise BaseExceptionGroup(
"msg3", [ValueError(), BaseExceptionGroup("msg4", [TypeError(), ValueError()])]
)

return app


@pytest.mark.trio
async def test_multi_error_handling(error_app: QuartTrio) -> None:
async def test_exception_group_handling(error_app: QuartTrio) -> None:
@error_app.errorhandler(TypeError)
async def handler(_: Exception) -> ResponseReturnValue:
return "", 201
Expand All @@ -35,7 +39,7 @@ async def handler(_: Exception) -> ResponseReturnValue:


@pytest.mark.trio
async def test_websocket_multi_error_handling(error_app: QuartTrio) -> None:
async def test_websocket_exception_group_handling(error_app: QuartTrio) -> None:
@error_app.errorhandler(TypeError)
async def handler(_: Exception) -> ResponseReturnValue:
return "", 201
Expand All @@ -49,14 +53,14 @@ async def handler(_: Exception) -> ResponseReturnValue:


@pytest.mark.trio
async def test_multi_error_unhandled(error_app: QuartTrio) -> None:
async def test_exception_group_unhandled(error_app: QuartTrio) -> None:
test_client = error_app.test_client()
response = await test_client.get("/")
assert response.status_code == 500


@pytest.mark.trio
async def test_websocket_multi_error_unhandled(error_app: QuartTrio) -> None:
async def test_websocket_exception_group_unhandled(error_app: QuartTrio) -> None:
test_client = error_app.test_client()
try:
async with test_client.websocket("/ws/") as test_websocket:
Expand Down
4 changes: 2 additions & 2 deletions tests/test_basic.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from pathlib import Path

import py
import pytest
from py._path.local import LocalPath
from quart import abort, Quart, ResponseReturnValue, send_file, websocket
from quart.testing import WebsocketResponseError

Expand Down Expand Up @@ -59,7 +59,7 @@ async def test_websocket_abort(app: Quart) -> None:


@pytest.mark.trio
async def test_send_file_path(tmpdir: LocalPath) -> None:
async def test_send_file_path(tmpdir: py.path.local) -> None:
app = QuartTrio(__name__)
file_ = tmpdir.join("send.img")
file_.write("something")
Expand Down