diff --git a/docs/discussion/multi_errors.rst b/docs/discussion/exception_groups.rst similarity index 52% rename from docs/discussion/multi_errors.rst rename to docs/discussion/exception_groups.rst index 6fa44f1..df0f5c6 100644 --- a/docs/discussion/multi_errors.rst +++ b/docs/discussion/exception_groups.rst @@ -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). diff --git a/docs/discussion/index.rst b/docs/discussion/index.rst index 2157a84..687078b 100644 --- a/docs/discussion/index.rst +++ b/docs/discussion/index.rst @@ -6,4 +6,4 @@ Discussions :maxdepth: 1 background_tasks.rst - multi_errors.rst + exception_groups.rst diff --git a/pyproject.toml b/pyproject.toml index 1c14fb0..0a2d720 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" [tool.poetry.dev-dependencies] tox = "*" diff --git a/src/quart_trio/app.py b/src/quart_trio/app.py index 250b227..76d784e 100644 --- a/src/quart_trio/app.py +++ b/src/quart_trio/app.py @@ -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 @@ -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 @@ -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 @@ -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 @@ -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) @@ -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) @@ -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 diff --git a/src/quart_trio/asgi.py b/src/quart_trio/asgi.py index 599f843..57b0fa4 100644 --- a/src/quart_trio/asgi.py +++ b/src/quart_trio/asgi.py @@ -2,6 +2,7 @@ from typing import AnyStr, cast, Optional, TYPE_CHECKING import trio +from exceptiongroup import BaseExceptionGroup from hypercorn.typing import ( ASGIReceiveCallable, ASGISendCallable, @@ -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, @@ -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, diff --git a/tests/test_app.py b/tests/test_app.py index 961264e..aefe7fe 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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 @@ -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 @@ -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 @@ -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: diff --git a/tests/test_basic.py b/tests/test_basic.py index eb9e4bd..c8f9c02 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -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 @@ -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")