././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789231915.1985178 quart_trio-0.13.0/LICENSE0000644000000000000000000000203215251301453011713 0ustar00Copyright P G Jones 2018. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789231915.1985178 quart_trio-0.13.0/README.rst0000644000000000000000000000531315251301453012402 0ustar00Quart-Trio ========== |Build Status| |docs| |pypi| |python| |license| Quart-Trio is an extension for `Quart `__ to support the `Trio `_ event loop. This is an alternative to using the asyncio event loop present in the Python standard library and supported by default in Quart. Quickstart ---------- QuartTrio can be installed via `pip `_, .. code-block:: console $ pip install quart-trio and requires Python 3.13 or higher (see `python version support `_ for reasoning). A minimal Quart example is, .. code-block:: python from quart import websocket from quart_trio import QuartTrio app = QuartTrio(__name__) @app.route('/') async def hello(): return 'hello' @app.websocket('/ws') async def ws(): while True: await websocket.send('hello') app.run() if the above is in a file called ``app.py`` it can be run as, .. code-block:: console $ python app.py To deploy in a production setting see the `deployment `_ documentation. Contributing ------------ Quart-Trio is developed on `GitHub `_. You are very welcome to open `issues `_ or propose `merge requests `_. Testing ~~~~~~~ The best way to test Quart-Trio is with Tox, .. code-block:: console $ pip install tox $ tox this will check the code style and run the tests. Help ---- The `Quart-Trio `__ and `Quart `__ documentation are the best places to start, after that try searching `stack overflow `_, if you still can't find an answer please `open an issue `_. .. |Build Status| image:: https://github.com/pgjones/quart-trio/actions/workflows/ci.yml/badge.svg :target: https://github.com/pgjones/quart-trio/commits/main .. |docs| image:: https://img.shields.io/badge/docs-passing-brightgreen.svg :target: https://quart-trio.readthedocs.io .. |pypi| image:: https://img.shields.io/pypi/v/quart-trio.svg :target: https://pypi.python.org/pypi/Quart-Trio/ .. |python| image:: https://img.shields.io/pypi/pyversions/quart-trio.svg :target: https://pypi.python.org/pypi/Quart-Trio/ .. |license| image:: https://img.shields.io/badge/license-MIT-blue.svg :target: https://github.com/pgjones/quart-trio/blob/main/LICENSE ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789231923.2431211 quart_trio-0.13.0/pyproject.toml0000644000000000000000000000404015251301463013624 0ustar00[project] name = "quart-trio" version = "0.13.0" description = "A Quart extension to provide trio support" authors = [ { name = "pgjones", email = "philip.graham.jones@googlemail.com" }, ] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Web Environment", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Software Development :: Libraries :: Python Modules", ] include = [ "src/quart_trio/py.typed", ] readme = "README.rst" repository = "https://github.com/pgjones/quart-trio/" dependencies = [ "hypercorn[trio] >= 0.12.0", "quart >= 0.23", "trio >= 0.19.0", ] requires-python = ">=3.13" [project.license] text = "MIT" [project.optional-dependencies] docs = [ "pydata_sphinx_theme", ] [tool.black] line-length = 100 target-version = [ "py313", ] [tool.isort] combine_as_imports = true force_grid_wrap = 0 include_trailing_comma = true known_first_party = "quart_trio, tests" line_length = 100 multi_line_output = 3 no_lines_before = "LOCALFOLDER" order_by_type = false reverse_relative = true [tool.mypy] allow_redefinition = true disallow_any_generics = false disallow_subclassing_any = true disallow_untyped_calls = false disallow_untyped_defs = true implicit_reexport = true no_implicit_optional = true show_error_codes = true strict = true strict_equality = true strict_optional = false warn_redundant_casts = true warn_return_any = false warn_unused_configs = true warn_unused_ignores = true [[tool.mypy.overrides]] module = [ "trio.*", ] ignore_missing_imports = true [tool.pytest.ini_options] addopts = "--no-cov-on-fail --showlocals --strict-markers" asyncio_default_fixture_loop_scope = "session" asyncio_mode = "auto" testpaths = [ "tests", ] [build-system] requires = [ "pdm-backend", ] build-backend = "pdm.backend" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789231915.1997976 quart_trio-0.13.0/src/quart_trio/__init__.py0000644000000000000000000000006515251301453016003 0ustar00from .app import QuartTrio __all__ = ("QuartTrio",) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789231915.1998622 quart_trio-0.13.0/src/quart_trio/app.py0000644000000000000000000002634415251301453015034 0ustar00import sys import warnings from typing import Any, Awaitable, Callable, Coroutine, Optional, ParamSpec, TypeVar, Union import trio from hypercorn.config import Config as HyperConfig from hypercorn.trio import serve from quart import Quart, request_started, websocket_started from quart.ctx import AppContext from quart.signals import got_serving_exception from quart.typing import FilePath, ResponseReturnValue, ResponseTypes from quart.utils import file_path_to_path from quart.wrappers import Request, Websocket from werkzeug.exceptions import HTTPException from .asgi import TrioASGIHTTPConnection, TrioASGILifespan, TrioASGIWebsocketConnection from .testing import TrioClient, TrioTestApp from .utils import run_sync from .wrappers import TrioRequest, TrioResponse, TrioWebsocket T = TypeVar("T") P = ParamSpec("P") class QuartTrio(Quart): nursery: trio.Nursery asgi_http_class = TrioASGIHTTPConnection asgi_lifespan_class = TrioASGILifespan asgi_websocket_class = TrioASGIWebsocketConnection event_class = trio.Event # type: ignore lock_class = trio.Lock # type: ignore request_class = TrioRequest response_class = TrioResponse test_app_class = TrioTestApp test_client_class = TrioClient # type: ignore websocket_class = TrioWebsocket def run( # type: ignore self, host: str = "127.0.0.1", port: int = 5000, debug: Optional[bool] = None, use_reloader: bool = True, ca_certs: Optional[str] = None, certfile: Optional[str] = None, keyfile: Optional[str] = None, **kwargs: Any, ) -> None: """Run this application. This is best used for development only, see using Gunicorn for production servers. Arguments: host: Hostname to listen on. By default this is loopback only, use 0.0.0.0 to have the server listen externally. port: Port number to listen on. debug: If set enable (or disable) debug mode and debug output. use_reloader: Automatically reload on code changes. ca_certs: Path to the SSL CA certificate file. certfile: Path to the SSL certificate file. ciphers: Ciphers to use for the SSL setup. keyfile: Path to the SSL key file. """ if kwargs: warnings.warn( f"Additional arguments, {','.join(kwargs.keys())}, are not supported.\n" "They may be supported by Hypercorn, which is the ASGI server Quart " "uses by default. This method is meant for development and debugging." ) scheme = "https" if certfile is not None and keyfile is not None else "http" print(f"Running on {scheme}://{host}:{port} (CTRL + C to quit)") # noqa: T201 trio.run(self.run_task, host, port, debug, ca_certs, certfile, keyfile) def run_task( self, host: str = "127.0.0.1", port: int = 5000, debug: Optional[bool] = None, ca_certs: Optional[str] = None, certfile: Optional[str] = None, keyfile: Optional[str] = None, shutdown_trigger: Optional[Callable[..., Awaitable[None]]] = None, task_status: trio.TaskStatus = trio.TASK_STATUS_IGNORED, ) -> Coroutine[None, None, None]: """Return a task that when awaited runs this application. This is best used for development only, see Hypercorn for production servers. Arguments: host: Hostname to listen on. By default this is loopback only, use 0.0.0.0 to have the server listen externally. port: Port number to listen on. debug: If set enable (or disable) debug mode and debug output. use_reloader: Automatically reload on code changes. loop: Asyncio loop to create the server in, if None, take default one. If specified it is the caller's responsibility to close and cleanup the loop. ca_certs: Path to the SSL CA certificate file. certfile: Path to the SSL certificate file. keyfile: Path to the SSL key file. """ config = HyperConfig() config.access_log_format = "%(h)s %(r)s %(s)s %(b)s %(D)s" config.accesslog = "-" config.bind = [f"{host}:{port}"] config.ca_certs = ca_certs config.certfile = certfile if debug is not None: config.debug = debug config.errorlog = config.accesslog config.keyfile = keyfile return serve( self, config, shutdown_trigger=shutdown_trigger, task_status=task_status # type: ignore ) def sync_to_async(self, func: Callable[P, T]) -> Callable[P, Awaitable[T]]: """Return a async function that will run the synchronous function *func*. This can be used as so,:: result = await app.sync_to_async(func)(*args, **kwargs) Override this method to change how the app converts sync code to be asynchronously callable. """ return run_sync(func) async def handle_request(self, request: Request) -> ResponseTypes: async with self.request_context(request) as ctx: try: return await self.full_dispatch_request(ctx) except trio.Cancelled: raise # Cancelled should be handled by serving code. except BaseExceptionGroup as error: filtered_error, _ = error.split(trio.Cancelled) if filtered_error is not None: raise filtered_error return await self.handle_exception(ctx, error) # type: ignore except Exception as error: return await self.handle_exception(ctx, error) finally: if request.scope.get("_quart._preserve_context", False): self._preserved_context = ctx.copy() async def full_dispatch_request(self, ctx: AppContext) -> ResponseTypes: """Adds pre and post processing to the request dispatching. Arguments: request_context: The context containing the request. """ try: await request_started.send_async(self, _sync_wrapper=self.ensure_async) # type: ignore result: ResponseReturnValue | HTTPException | None result = await self.preprocess_request(ctx) if result is None: result = await self.dispatch_request(ctx) except (Exception, BaseExceptionGroup) as error: result = await self.handle_user_exception(ctx, error) return await self.finalize_request(ctx, result) async def handle_user_exception( self, ctx: AppContext, error: Union[Exception, BaseExceptionGroup] ) -> Union[HTTPException, ResponseReturnValue]: if isinstance(error, BaseExceptionGroup): for exception in error.exceptions: try: return await self.handle_user_exception(ctx, exception) # type: ignore except Exception: pass # No handler for this error # Not found a single handler, re-raise the error raise error else: return await super().handle_user_exception(ctx, error) async def handle_websocket(self, websocket: Websocket) -> ResponseTypes | None: async with self.websocket_context(websocket) as ctx: try: return await self.full_dispatch_websocket(ctx) except trio.Cancelled: raise # Cancelled should be handled by serving code. except BaseExceptionGroup as error: filtered_error, _ = error.split(trio.Cancelled) if filtered_error is not None: raise filtered_error return await self.handle_websocket_exception(ctx, error) # type: ignore except Exception as error: return await self.handle_websocket_exception(ctx, error) finally: if websocket.scope.get("_quart._preserve_context", False): self._preserved_context = ctx.copy() async def full_dispatch_websocket(self, ctx: AppContext) -> ResponseTypes: """Adds pre and post processing to the websocket dispatching. Arguments: websocket_context: The context containing the websocket. """ try: await websocket_started.send_async( self, _sync_wrapper=self.ensure_async # type: ignore ) result: ResponseReturnValue | HTTPException | None result = await self.preprocess_websocket(ctx) if result is None: result = await self.dispatch_websocket(ctx) except (Exception, BaseExceptionGroup) as error: result = await self.handle_user_exception(ctx, error) return await self.finalize_websocket(ctx, result) async def open_instance_resource( self, path: FilePath, mode: str = "rb" ) -> trio._file_io.AsyncIOWrapper: """Open a file for reading. Use as .. code-block:: python async with await app.open_instance_resource(path) as file_: await file_.read() """ return await trio.open_file(self.instance_path / file_path_to_path(path), mode) async def open_resource(self, path: FilePath, mode: str = "rb") -> trio._file_io.AsyncIOWrapper: """Open a file for reading. Use as .. code-block:: python async with await app.open_resource(path) as file_: await file_.read() """ if mode not in {"r", "rb"}: raise ValueError("Files can only be opened for reading") return await trio.open_file(self.root_path / file_path_to_path(path), mode) def add_background_task(self, func: Callable, *args: Any, **kwargs: Any) -> None: async def _wrapper() -> None: async with self.app_context() as ctx: try: await self.ensure_async(func)(*args, **kwargs) except (BaseExceptionGroup, Exception) as error: await self.handle_background_exception(ctx, error) # type: ignore self.nursery.start_soon(_wrapper) async def shutdown(self) -> None: if self.config["BACKGROUND_TASK_SHUTDOWN_TIMEOUT"] is not None: self.nursery.cancel_scope.deadline = ( trio.current_time() + self.config["BACKGROUND_TASK_SHUTDOWN_TIMEOUT"] ) else: self.nursery.cancel_scope.cancel() async with self.app_context() as ctx: try: for gen in reversed(self.while_serving_gens): try: await gen.__anext__() except StopAsyncIteration: pass else: raise RuntimeError("While serving generator didn't terminate") for func in self.after_serving_funcs: await self.ensure_async(func)() except Exception as error: await got_serving_exception.send_async( self, _sync_wrapper=self.ensure_async, exception=error # type: ignore ) self.log_exception(ctx, sys.exc_info()) raise ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789231915.1998622 quart_trio-0.13.0/src/quart_trio/asgi.py0000644000000000000000000001277515251301453015202 0ustar00from typing import cast, Optional, TYPE_CHECKING import trio from hypercorn.typing import ( ASGIReceiveCallable, ASGISendCallable, LifespanScope, LifespanShutdownCompleteEvent, LifespanShutdownFailedEvent, LifespanStartupCompleteEvent, LifespanStartupFailedEvent, ) from quart.asgi import _convert_os_error, ASGIHTTPConnection, ASGIWebsocketConnection from quart.signals import websocket_received from quart.wrappers import Request, Response, Websocket # noqa: F401 if TYPE_CHECKING: from quart_trio import QuartTrio # noqa: F401 class TrioASGIHTTPConnection(ASGIHTTPConnection): async def __call__(self, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None: send = _convert_os_error(send) request = self._create_request_from_scope(send) async with trio.open_nursery() as nursery: nursery.start_soon(self.handle_messages, nursery, request, receive) nursery.start_soon(self.handle_request, nursery, request, send) async def handle_messages( # type: ignore self, nursery: trio.Nursery, request: Request, receive: ASGIReceiveCallable ) -> None: while True: message = await receive() if message["type"] == "http.request": await request.body.put(message.get("body", b"")) if not message.get("more_body", False): request.body.set_complete() elif message["type"] == "http.disconnect": self._disconnected = True request.body.disconnect() nursery.cancel_scope.cancel() return async def handle_request( # type: ignore self, nursery: trio.Nursery, request: Request, send: ASGISendCallable ) -> None: response = await self.app.handle_request(request) if isinstance(response, Response) and response.timeout != Ellipsis: timeout = cast(Optional[float], response.timeout) else: timeout = self.app.config["RESPONSE_TIMEOUT"] if timeout is not None: with trio.move_on_after(timeout): await self._send_response(send, response) else: await self._send_response(send, response) nursery.cancel_scope.cancel() class TrioASGIWebsocketConnection(ASGIWebsocketConnection): async def __call__(self, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None: send = _convert_os_error(send) websocket = self._create_websocket_from_scope(send) async with trio.open_nursery() as nursery: nursery.start_soon(self.handle_messages, nursery, websocket, receive) nursery.start_soon(self.handle_websocket, nursery, websocket, send) async def handle_messages( # type: ignore self, nursery: trio.Nursery, websocket: Websocket, receive: ASGIReceiveCallable ) -> None: while True: event = await receive() if event["type"] == "websocket.receive": message = event.get("bytes") or event["text"] await websocket_received.send_async( message, _sync_wrapper=self.app.ensure_async # type: ignore ) await websocket.buffer.put(message) elif event["type"] == "websocket.disconnect": self._disconnected = True websocket.buffer.disconnect() break nursery.cancel_scope.cancel() async def handle_websocket( # type: ignore self, nursery: trio.Nursery, websocket: Websocket, send: ASGISendCallable ) -> None: await super().handle_websocket(websocket, send) nursery.cancel_scope.cancel() class TrioASGILifespan: def __init__(self, app: "QuartTrio", scope: LifespanScope) -> None: self.app = app async def __call__(self, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None: async with trio.open_nursery() as nursery: self.app.nursery = nursery while True: event = await receive() if event["type"] == "lifespan.startup": try: await self.app.startup() except (Exception, BaseExceptionGroup) as error: await send( cast( LifespanStartupFailedEvent, {"type": "lifespan.startup.failed", "message": str(error)}, ), ) else: await send( cast( LifespanStartupCompleteEvent, {"type": "lifespan.startup.complete"} ) ) elif event["type"] == "lifespan.shutdown": try: await self.app.shutdown() except (Exception, BaseExceptionGroup) as error: await send( cast( LifespanShutdownFailedEvent, {"type": "lifespan.shutdown.failed", "message": str(error)}, ), ) else: await send( cast( LifespanShutdownCompleteEvent, {"type": "lifespan.shutdown.complete"}, ), ) break ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789231915.1998622 quart_trio-0.13.0/src/quart_trio/datastructures.py0000644000000000000000000000434315251301453017324 0ustar00from __future__ import annotations from asyncio import QueueEmpty, QueueShutDown from os import PathLike from typing import Generic, TypeVar import trio from quart.datastructures import FileStorage from trio import open_file, Path, wrap_file T = TypeVar("T") class TrioQueue(Generic[T]): """Queue operations used by Quart's body and websocket buffers.""" def __init__(self) -> None: self._send, self._receive = trio.open_memory_channel[T](float("inf")) async def put(self, value: T) -> None: try: await self._send.send(value) except trio.ClosedResourceError: raise QueueShutDown from None def put_nowait(self, value: T) -> None: try: self._send.send_nowait(value) except trio.ClosedResourceError: raise QueueShutDown from None async def get(self) -> T: try: return await self._receive.receive() except trio.EndOfChannel: raise QueueShutDown from None def get_nowait(self) -> T: try: return self._receive.receive_nowait() except trio.WouldBlock: raise QueueEmpty from None except trio.EndOfChannel: raise QueueShutDown from None def empty(self) -> bool: return self._receive.statistics().current_buffer_used == 0 def shutdown(self) -> None: self._send.close() class TrioFileStorage(FileStorage): async def save(self, destination: PathLike, buffer_size: int = 16384) -> None: # type: ignore wrapped_stream = wrap_file(self.stream) async with await open_file(destination, "wb") as file_: data = await wrapped_stream.read(buffer_size) while data != b"": await file_.write(data) data = await wrapped_stream.read(buffer_size) async def load(self, source: PathLike, buffer_size: int = 16384) -> None: path = Path(source) self.filename = path.name wrapped_stream = wrap_file(self.stream) async with await open_file(path, "rb") as file_: data = await file_.read(buffer_size) while data != b"": await wrapped_stream.write(data) data = await file_.read(buffer_size) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789231915.1998622 quart_trio-0.13.0/src/quart_trio/formparser.py0000644000000000000000000000027015251301453016422 0ustar00from quart.formparser import FormDataParser from quart_trio.datastructures import TrioFileStorage class TrioFormDataParser(FormDataParser): file_storage_class = TrioFileStorage ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789231915.1998622 quart_trio-0.13.0/src/quart_trio/py.typed0000644000000000000000000000000715251301453015365 0ustar00Marker ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789231915.1998622 quart_trio-0.13.0/src/quart_trio/testing/__init__.py0000644000000000000000000000021115251301453017451 0ustar00from __future__ import annotations from .app import TrioTestApp from .client import TrioClient __all__ = ("TrioClient", "TrioTestApp") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789231915.2002358 quart_trio-0.13.0/src/quart_trio/testing/app.py0000644000000000000000000000511015251301453016475 0ustar00from __future__ import annotations from contextlib import AbstractAsyncContextManager from types import TracebackType import trio from quart.app import Quart from quart.testing.app import DEFAULT_TIMEOUT, LifespanError from quart.typing import TestClientProtocol class TrioTestApp: def __init__( self, app: "Quart", startup_timeout: int = DEFAULT_TIMEOUT, shutdown_timeout: int = DEFAULT_TIMEOUT, ) -> None: self.app = app self.startup_timeout = startup_timeout self.shutdown_timeout = shutdown_timeout self._startup = trio.Event() self._shutdown = trio.Event() self._app_send_channel, self._app_receive_channel = trio.open_memory_channel[dict](10) self._nursery_manager: AbstractAsyncContextManager[trio.Nursery] self._nursery: trio.Nursery def test_client(self) -> TestClientProtocol: return self.app.test_client() async def startup(self) -> None: scope = {"type": "lifespan", "asgi": {"spec_version": "2.0"}} self._nursery.start_soon(self.app, scope, self._asgi_receive, self._asgi_send) # type: ignore # noqa: E501 await self._app_send_channel.send({"type": "lifespan.startup"}) with trio.fail_after(self.startup_timeout): await self._startup.wait() async def shutdown(self) -> None: await self._app_send_channel.send({"type": "lifespan.shutdown"}) with trio.fail_after(self.shutdown_timeout): await self._shutdown.wait() async def __aenter__(self) -> "TrioTestApp": self._nursery_manager = trio.open_nursery() self._nursery = await self._nursery_manager.__aenter__() await self.startup() return self async def __aexit__(self, exc_type: type, exc_value: BaseException, tb: TracebackType) -> None: await self.shutdown() await self._nursery_manager.__aexit__(exc_type, exc_value, tb) async def _asgi_receive(self) -> dict: return await self._app_receive_channel.receive() async def _asgi_send(self, message: dict) -> None: if message["type"] == "lifespan.startup.complete": self._startup.set() elif message["type"] == "lifespan.shutdown.complete": self._shutdown.set() elif message["type"] == "lifespan.startup.failed": self._startup.set() raise LifespanError(f"Error during startup {message['message']}") elif message["type"] == "lifespan.shutdown.failed": self._shutdown.set() raise LifespanError(f"Error during shutdown {message['message']}") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789231915.2002358 quart_trio-0.13.0/src/quart_trio/testing/client.py0000644000000000000000000000072715251301453017204 0ustar00from __future__ import annotations from typing import Type from quart.testing.client import QuartClient from quart.typing import TestHTTPConnectionProtocol, TestWebsocketConnectionProtocol from .connections import TestHTTPConnection, TestWebsocketConnection class TrioClient(QuartClient): http_connection_class: Type[TestHTTPConnectionProtocol] = TestHTTPConnection websocket_connection_class: Type[TestWebsocketConnectionProtocol] = TestWebsocketConnection ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789231915.2002358 quart_trio-0.13.0/src/quart_trio/testing/connections.py0000644000000000000000000001571415251301453020252 0ustar00from __future__ import annotations from types import TracebackType from typing import Any, Awaitable, List, Optional, Tuple, Union import trio from hypercorn.typing import HTTPScope, WebsocketScope from quart.app import Quart from quart.json import dumps, loads from quart.testing.connections import WebsocketResponseError from quart.typing import TestHTTPConnectionProtocol, TestWebsocketConnectionProtocol from quart.utils import decode_headers from quart.wrappers import Response from quart.wrappers.base import ClientDisconnectedError from werkzeug.datastructures import Headers class TestHTTPConnection: def __init__(self, app: Quart, scope: HTTPScope, _preserve_context: bool = False) -> None: self.app = app self.headers: Optional[Headers] = None self.push_promises: List[Tuple[str, Headers]] = [] self.response_data = bytearray() self.scope = scope self.status_code: Optional[int] = None self._preserve_context = _preserve_context self._server_send, self._server_receive = trio.open_memory_channel[dict](10) self._client_send, self._client_receive = trio.open_memory_channel[Union[bytes, Exception]]( 10 ) async def send(self, data: bytes) -> None: await self._server_send.send({"type": "http.request", "body": data, "more_body": True}) async def send_complete(self) -> None: await self._server_send.send({"type": "http.request", "body": b"", "more_body": False}) async def receive(self) -> bytes: data = await self._client_receive.receive() if isinstance(data, Exception): raise data else: return data async def disconnect(self) -> None: await self._server_send.send({"type": "http.disconnect"}) await self._server_send.aclose() async def __aenter__(self) -> TestHTTPConnectionProtocol: self._nursery_manager = trio.open_nursery() nursery = await self._nursery_manager.__aenter__() nursery.start_soon(self.app, self.scope, self._asgi_receive, self._asgi_send) # type: ignore # noqa: E501 return self async def __aexit__(self, exc_type: type, exc_value: BaseException, tb: TracebackType) -> None: if exc_type is not None: await self.disconnect() await self._nursery_manager.__aexit__(exc_type, exc_value, tb) try: async with self._client_receive: async for data in self._client_receive: if isinstance(data, bytes): self.response_data.extend(data) else: raise data except trio.ClosedResourceError: pass async def as_response(self) -> Response: try: async with self._client_receive: async for data in self._client_receive: if isinstance(data, bytes): self.response_data.extend(data) except trio.ClosedResourceError: pass return self.app.response_class(bytes(self.response_data), self.status_code, self.headers) async def _asgi_receive(self) -> dict: return await self._server_receive.receive() async def _asgi_send(self, message: dict) -> None: if message["type"] == "http.response.start": self.headers = decode_headers(message["headers"]) self.status_code = message["status"] elif message["type"] == "http.response.body": await self._client_send.send(message["body"]) if not message.get("more_body", False): await self._client_send.aclose() elif message["type"] == "http.response.push": self.push_promises.append((message["path"], decode_headers(message["headers"]))) class TestWebsocketConnection: def __init__(self, app: Quart, scope: WebsocketScope) -> None: self.accepted = False self.app = app self.headers: Optional[Headers] = None self.response_data = bytearray() self.scope = scope self.status_code: Optional[int] = None self._server_send, self._server_receive = trio.open_memory_channel[dict](10) self._client_send, self._client_receive = trio.open_memory_channel[ Union[bytes, str, Exception] ](10) self._task: Awaitable[None] = None async def __aenter__(self) -> TestWebsocketConnectionProtocol: self._nursery_manager = trio.open_nursery() nursery = await self._nursery_manager.__aenter__() nursery.start_soon(self.app, self.scope, self._asgi_receive, self._asgi_send) # type: ignore # noqa: E501 return self async def __aexit__(self, exc_type: type, exc_value: BaseException, tb: TracebackType) -> None: await self.disconnect() await self._nursery_manager.__aexit__(exc_type, exc_value, tb) async def receive(self) -> str | bytes: data = await self._client_receive.receive() if isinstance(data, Exception): raise data else: return data async def send(self, data: str | bytes) -> None: if isinstance(data, str): await self._server_send.send({"type": "websocket.receive", "text": data}) else: await self._server_send.send({"type": "websocket.receive", "bytes": data}) async def receive_json(self) -> Any: data = await self.receive() return loads(data) async def send_json(self, data: Any) -> None: raw = dumps(data) await self.send(raw) async def close(self, code: int) -> None: await self._server_send.send({"type": "websocket.close", "code": int}) await self._server_send.aclose() async def disconnect(self) -> None: await self._server_send.send({"type": "websocket.disconnect"}) await self._server_send.aclose() async def _asgi_receive(self) -> dict: return await self._server_receive.receive() async def _asgi_send(self, message: dict) -> None: if message["type"] == "websocket.accept": self.accepted = True elif message["type"] == "websocket.send": await self._client_send.send(message.get("bytes") or message.get("text")) elif message["type"] == "websocket.http.response.start": self.headers = decode_headers(message["headers"]) self.status_code = message["status"] elif message["type"] == "websocket.http.response.body": self.response_data.extend(message["body"]) if not message.get("more_body", False): await self._client_send.send( WebsocketResponseError( self.app.response_class( bytes(self.response_data), self.status_code, self.headers ) ) ) elif message["type"] == "websocket.close": await self._client_send.send(ClientDisconnectedError(message.get("code", 1000))) await self._client_send.aclose() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789231915.2002358 quart_trio-0.13.0/src/quart_trio/utils.py0000644000000000000000000000326315251301453015407 0ustar00from contextvars import copy_context from functools import partial, wraps from inspect import isgenerator from typing import Any, AsyncGenerator, Callable, Coroutine, Generator import trio def run_sync(func: Callable[..., Any]) -> Callable[..., Coroutine[Any, None, None]]: """Ensure that the sync function is run without blocking. If the *func* is not a coroutine it will be wrapped such that it runs on a thread. This ensures that synchronous functions do not block the event loop. """ @wraps(func) async def _wrapper(*args: Any, **kwargs: Any) -> Any: result = await trio.to_thread.run_sync(copy_context().run, partial(func, *args, **kwargs)) if isgenerator(result): return run_sync_iterable(result) # type: ignore else: return result _wrapper._quart_async_wrapper = True # type: ignore return _wrapper def run_sync_iterable(iterable: Generator[Any, None, None]) -> AsyncGenerator[Any, None]: async def _gen_wrapper() -> AsyncGenerator[Any, None]: # Wrap the generator such that each iteration runs # in the executor. Then rationalise the raised # errors so that it ends. def _inner() -> Any: # https://bugs.python.org/issue26221 # StopIteration errors are swallowed by the # run_in_exector method try: return next(iterable) except StopIteration: raise StopAsyncIteration() while True: try: yield await trio.to_thread.run_sync(partial(copy_context().run, _inner)) except StopAsyncIteration: return return _gen_wrapper() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789231915.2002358 quart_trio-0.13.0/src/quart_trio/wrappers/__init__.py0000644000000000000000000000026415251301453017647 0ustar00from .request import TrioRequest from .response import TrioResponse from .websocket import TrioWebsocket __all__ = ( "TrioRequest", "TrioResponse", "TrioWebsocket", ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789231915.2005703 quart_trio-0.13.0/src/quart_trio/wrappers/request.py0000644000000000000000000000627715251301453017612 0ustar00from typing import Literal, Optional, overload, Union import trio from quart.wrappers.request import Body, Request from werkzeug.exceptions import RequestEntityTooLarge, RequestTimeout from ..datastructures import TrioQueue from ..formparser import TrioFormDataParser class EventWrapper: def __init__(self) -> None: self._event = trio.Event() def clear(self) -> None: self._event = trio.Event() async def wait(self) -> None: await self._event.wait() def is_set(self) -> bool: return self._event.is_set() def set(self) -> None: self._event.set() class TrioBody(Body): def __init__( self, expected_content_length: Optional[int], max_content_length: Optional[int] ) -> None: self._data = None self._complete = EventWrapper() # type: ignore self._queue = TrioQueue[bytes]() # type: ignore self._max_content_length = max_content_length # Exceptions must be raised within application (not ASGI) # calls, this is achieved by having the ASGI methods set this # to an exception on error. self._must_raise: Optional[Exception] = None if ( expected_content_length is not None and max_content_length is not None and expected_content_length > max_content_length ): self._must_raise = RequestEntityTooLarge() class TrioRequest(Request): body_class = TrioBody form_data_parser_class = TrioFormDataParser lock_class = trio.Lock # type: ignore @overload async def get_data( self, cache: bool, as_text: Literal[False], parse_form_data: bool ) -> bytes: ... @overload async def get_data(self, cache: bool, as_text: Literal[True], parse_form_data: bool) -> str: ... @overload async def get_data( self, cache: bool = True, as_text: bool = False, parse_form_data: bool = False ) -> Union[str, bytes]: ... async def get_data( self, cache: bool = True, as_text: bool = False, parse_form_data: bool = False ) -> Union[str, bytes]: if parse_form_data: await self._load_form_data() timeout = float("inf") if self.body_timeout is None else self.body_timeout with trio.move_on_after(timeout) as cancel_scope: raw_data = await self.body if cancel_scope.cancelled_caught: raise RequestTimeout() if not cache: self.body.clear() if as_text: return raw_data.decode() else: return raw_data async def _load_form_data(self) -> None: async with self._parsing_lock: if self._form is None: parser = self.make_form_data_parser() timeout = float("inf") if self.body_timeout is None else self.body_timeout with trio.move_on_after(timeout) as cancel_scope: self._form, self._files = await parser.parse( self.body, self.mimetype, self.content_length, self.mimetype_params, ) if cancel_scope.cancelled_caught: raise RequestTimeout() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789231915.2005703 quart_trio-0.13.0/src/quart_trio/wrappers/response.py0000644000000000000000000000615415251301453017752 0ustar00import os from inspect import isasyncgen, isgenerator from types import TracebackType from typing import AsyncGenerator, Iterable, Optional, Union import trio from quart.wrappers.response import _raise_if_invalid_range, IterableBody, Response, ResponseBody from ..utils import run_sync_iterable class TrioFileBody(ResponseBody): """Provides an async file accessor with range setting. The :attr:`Response.response` attribute must be async-iterable and yield bytes, which this wrapper does for a file. In addition it allows a range to be set on the file, thereby supporting conditional requests. """ buffer_size = 8192 def __init__( self, file_path: Union[str, bytes, os.PathLike], *, buffer_size: Optional[int] = None ) -> None: self.file_path = file_path self.size = os.path.getsize(self.file_path) self.begin = 0 self.end = self.size if buffer_size is not None: self.buffer_size = buffer_size self.file: Optional[trio._file_io.AsyncIOWrapper] = None async def __aenter__(self) -> "TrioFileBody": self.file = await trio.open_file(self.file_path, mode="rb") await self.file.__aenter__() await self.file.seek(self.begin) return self async def __aexit__(self, exc_type: type, exc_value: BaseException, tb: TracebackType) -> None: await self.file.__aexit__(exc_type, exc_value, tb) def __aiter__(self) -> "TrioFileBody": return self async def __anext__(self) -> bytes: current = await self.file.tell() if current >= self.end: raise StopAsyncIteration() read_size = min(self.buffer_size, self.end - current) chunk = await self.file.read(read_size) if chunk: return chunk else: raise StopAsyncIteration() async def convert_to_sequence(self) -> bytes: result = bytearray() async with self as response: async for data in response: result.extend(data) return bytes(result) async def make_conditional( self, begin: int, end: Optional[int], max_partial_size: Optional[int] = None ) -> int: self.begin = begin self.end = self.size if end is None else end if max_partial_size is not None: self.end = min(self.begin + max_partial_size, self.end) _raise_if_invalid_range(self.begin, self.end, self.size) return self.size class TrioIterableBody(IterableBody): def __init__(self, iterable: Union[AsyncGenerator[bytes, None], Iterable]) -> None: self.iter: AsyncGenerator[bytes, None] if isasyncgen(iterable): self.iter = iterable elif isgenerator(iterable): self.iter = run_sync_iterable(iterable) # type: ignore else: async def _aiter() -> AsyncGenerator[bytes, None]: for data in iterable: # type: ignore yield data self.iter = _aiter() class TrioResponse(Response): file_body_class = TrioFileBody # type: ignore iterable_body_class = TrioIterableBody # type: ignore ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789231915.2005703 quart_trio-0.13.0/src/quart_trio/wrappers/websocket.py0000644000000000000000000000061315251301453020074 0ustar00from quart.wrappers.websocket import Buffer, Websocket from ..datastructures import TrioQueue class TrioBuffer(Buffer): def __init__(self) -> None: self._queue = TrioQueue[bytes | str]() # type: ignore class TrioWebsocket(Websocket): buffer_class = TrioBuffer async def send(self, data: str | bytes) -> None: await self.accept() await self._send(data) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789231915.2005703 quart_trio-0.13.0/tests/test_app.py0000644000000000000000000000572615251301453014256 0ustar00import sys from typing import NoReturn import pytest from quart import ResponseReturnValue from quart.testing import WebsocketResponseError from quart_trio import QuartTrio if sys.version_info < (3, 11): from exceptiongroup import BaseExceptionGroup @pytest.fixture(name="error_app", scope="function") def _error_app() -> QuartTrio: app = QuartTrio(__name__) @app.route("/") async def index() -> NoReturn: raise BaseExceptionGroup( "msg1", [ValueError(), BaseExceptionGroup("msg2", [TypeError(), ValueError()])] ) @app.websocket("/ws/") async def ws() -> NoReturn: raise BaseExceptionGroup( "msg3", [ValueError(), BaseExceptionGroup("msg4", [TypeError(), ValueError()])] ) return app @pytest.mark.trio async def test_exception_group_handling(error_app: QuartTrio) -> None: @error_app.errorhandler(TypeError) async def handler(_: Exception) -> ResponseReturnValue: return "", 201 test_client = error_app.test_client() response = await test_client.get("/") assert response.status_code == 201 @pytest.mark.trio async def test_websocket_exception_group_handling(error_app: QuartTrio) -> None: @error_app.errorhandler(TypeError) async def handler(_: Exception) -> ResponseReturnValue: return "", 201 test_client = error_app.test_client() try: async with test_client.websocket("/ws/") as test_websocket: await test_websocket.receive() except BaseExceptionGroup as error: for exception in error.exceptions: if isinstance(exception, WebsocketResponseError): assert exception.response.status_code == 201 @pytest.mark.trio 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_exception_group_unhandled(error_app: QuartTrio) -> None: test_client = error_app.test_client() try: async with test_client.websocket("/ws/") as test_websocket: await test_websocket.receive() except BaseExceptionGroup as error: for exception in error.exceptions: if isinstance(exception, WebsocketResponseError): assert exception.response.status_code == 500 @pytest.mark.trio async def test_test_app() -> None: startup = False shutdown = False app = QuartTrio(__name__) @app.before_serving async def before() -> None: nonlocal startup startup = True @app.after_serving async def after() -> None: nonlocal shutdown shutdown = True @app.route("/") async def index() -> str: return "" async with app.test_app() as test_app: assert startup assert app.nursery is not None test_client = test_app.test_client() await test_client.get("/") assert not shutdown assert shutdown ././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1789231915.20081 quart_trio-0.13.0/tests/test_asgi.py0000644000000000000000000000250415251301453014410 0ustar00from unittest.mock import AsyncMock import pytest import trio from hypercorn.typing import WebsocketScope from quart_trio.app import QuartTrio from quart_trio.asgi import TrioASGIWebsocketConnection @pytest.mark.trio async def test_websocket_complete_on_disconnect() -> None: scope: WebsocketScope = { "type": "websocket", "asgi": {}, "http_version": "1.1", "scheme": "wss", "path": "ws://quart/path", "raw_path": b"/", "query_string": b"", "root_path": "", "headers": [(b"host", b"quart")], "client": ("127.0.0.1", 80), "server": None, "subprotocols": [], "extensions": {"websocket.http.response": {}}, "state": {}, # type: ignore[typeddict-item] } connection = TrioASGIWebsocketConnection(QuartTrio(__name__), scope) websocket = connection._create_websocket_from_scope(AsyncMock()) send_channel, receive_channel = trio.open_memory_channel[dict](0) async with trio.open_nursery() as nursery: nursery.start_soon( connection.handle_messages, nursery, websocket, receive_channel.receive # type: ignore ) await send_channel.send({"type": "websocket.disconnect"}) await trio.sleep(1) # Simulate doing something else assert nursery.cancel_scope.cancelled_caught ././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1789231915.20081 quart_trio-0.13.0/tests/test_basic.py0000644000000000000000000000400215251301453014541 0ustar00import sys from pathlib import Path import pytest from quart import abort, Quart, ResponseReturnValue, send_file, websocket from quart.testing import WebsocketResponseError from quart_trio import QuartTrio if sys.version_info < (3, 11): from exceptiongroup import BaseExceptionGroup @pytest.fixture def app() -> Quart: app = QuartTrio(__name__) @app.route("/") async def index() -> ResponseReturnValue: return "index" @app.websocket("/ws/") async def ws() -> None: # async for message in websocket: while True: message = await websocket.receive() await websocket.send(message) @app.websocket("/ws/abort/") async def ws_abort() -> None: abort(401) return app @pytest.mark.trio async def test_index(app: Quart) -> None: test_client = app.test_client() response = await test_client.get("/") assert response.status_code == 200 assert b"index" in (await response.get_data()) @pytest.mark.trio async def test_websocket(app: Quart) -> None: test_client = app.test_client() data = b"bob" async with test_client.websocket("/ws/") as test_websocket: await test_websocket.send(data) result = await test_websocket.receive() assert result == data @pytest.mark.trio async def test_websocket_abort(app: Quart) -> None: test_client = app.test_client() try: async with test_client.websocket("/ws/abort/") as test_websocket: await test_websocket.receive() except BaseExceptionGroup as error: for exception in error.exceptions: if isinstance(exception, WebsocketResponseError): assert exception.response.status_code == 401 @pytest.mark.trio async def test_send_file_path(tmp_path: Path) -> None: app = QuartTrio(__name__) file_ = tmp_path / "send.img" file_.write_text("something") async with app.app_context(): response = await send_file(file_) assert (await response.get_data(as_text=False)) == file_.read_bytes() ././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1789231915.20081 quart_trio-0.13.0/tests/test_request.py0000644000000000000000000000205215251301453015153 0ustar00import pytest import trio from quart.wrappers.base import ClientDisconnectedError from werkzeug.exceptions import RequestEntityTooLarge from quart_trio.wrappers.request import TrioBody @pytest.mark.trio async def test_body_exceeds_max_content_length() -> None: max_content_length = 5 body = TrioBody(None, max_content_length) await body.put(b" " * (max_content_length + 1)) body.set_complete() with pytest.raises(RequestEntityTooLarge): await body @pytest.mark.trio async def test_body_streaming() -> None: body = TrioBody(None, None) async def produce() -> None: await body.put(b"first") await body.put(b"second") body.set_complete() async with trio.open_nursery() as nursery: nursery.start_soon(produce) data = b"".join([chunk async for chunk in body]) assert data == b"firstsecond" @pytest.mark.trio async def test_body_disconnect() -> None: body = TrioBody(None, None) body.disconnect() with pytest.raises(ClientDisconnectedError): await body.get() ././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1789231915.20081 quart_trio-0.13.0/tests/test_sync.py0000644000000000000000000000227215251301453014443 0ustar00import threading from typing import Generator import pytest from quart import request, ResponseReturnValue from quart_trio import QuartTrio @pytest.fixture(name="app") def _app() -> QuartTrio: app = QuartTrio(__name__) @app.route("/", methods=["GET", "POST"]) def index() -> ResponseReturnValue: return request.method @app.route("/gen") def gen() -> ResponseReturnValue: def _gen() -> Generator[bytes, None, None]: yield b"%d" % threading.current_thread().ident for _ in range(2): yield b"b" return _gen(), 200 return app @pytest.mark.trio async def test_sync_request_context(app: QuartTrio) -> None: test_client = app.test_client() response = await test_client.get("/") assert b"GET" in (await response.get_data()) response = await test_client.post("/") assert b"POST" in (await response.get_data()) @pytest.mark.trio async def test_sync_generator(app: QuartTrio) -> None: test_client = app.test_client() response = await test_client.get("/gen") result = await response.get_data() assert result[-2:] == b"bb" assert int(result[:-2]) != threading.current_thread().ident quart_trio-0.13.0/PKG-INFO0000644000000000000000000000713600000000000011757 0ustar00Metadata-Version: 2.1 Name: quart-trio Version: 0.13.0 Summary: A Quart extension to provide trio support Author-Email: pgjones License: MIT Classifier: Development Status :: 3 - Alpha Classifier: Environment :: Web Environment Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: 3.14 Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content Classifier: Topic :: Software Development :: Libraries :: Python Modules Requires-Python: >=3.13 Requires-Dist: hypercorn[trio]>=0.12.0 Requires-Dist: quart>=0.23 Requires-Dist: trio>=0.19.0 Provides-Extra: docs Requires-Dist: pydata_sphinx_theme; extra == "docs" Description-Content-Type: text/x-rst Quart-Trio ========== |Build Status| |docs| |pypi| |python| |license| Quart-Trio is an extension for `Quart `__ to support the `Trio `_ event loop. This is an alternative to using the asyncio event loop present in the Python standard library and supported by default in Quart. Quickstart ---------- QuartTrio can be installed via `pip `_, .. code-block:: console $ pip install quart-trio and requires Python 3.13 or higher (see `python version support `_ for reasoning). A minimal Quart example is, .. code-block:: python from quart import websocket from quart_trio import QuartTrio app = QuartTrio(__name__) @app.route('/') async def hello(): return 'hello' @app.websocket('/ws') async def ws(): while True: await websocket.send('hello') app.run() if the above is in a file called ``app.py`` it can be run as, .. code-block:: console $ python app.py To deploy in a production setting see the `deployment `_ documentation. Contributing ------------ Quart-Trio is developed on `GitHub `_. You are very welcome to open `issues `_ or propose `merge requests `_. Testing ~~~~~~~ The best way to test Quart-Trio is with Tox, .. code-block:: console $ pip install tox $ tox this will check the code style and run the tests. Help ---- The `Quart-Trio `__ and `Quart `__ documentation are the best places to start, after that try searching `stack overflow `_, if you still can't find an answer please `open an issue `_. .. |Build Status| image:: https://github.com/pgjones/quart-trio/actions/workflows/ci.yml/badge.svg :target: https://github.com/pgjones/quart-trio/commits/main .. |docs| image:: https://img.shields.io/badge/docs-passing-brightgreen.svg :target: https://quart-trio.readthedocs.io .. |pypi| image:: https://img.shields.io/pypi/v/quart-trio.svg :target: https://pypi.python.org/pypi/Quart-Trio/ .. |python| image:: https://img.shields.io/pypi/pyversions/quart-trio.svg :target: https://pypi.python.org/pypi/Quart-Trio/ .. |license| image:: https://img.shields.io/badge/license-MIT-blue.svg :target: https://github.com/pgjones/quart-trio/blob/main/LICENSE