From 29222a53bb23007c79a0a5f7844848f09343a49e Mon Sep 17 00:00:00 2001 From: Yehor Smoliakov Date: Sat, 10 Oct 2026 04:11:07 -0700 Subject: [PATCH] test: cover HTTP, WebSocket and database application lifecycles --- .github/workflows/async-ecosystem.yml | 67 +++++ docs/async-ecosystem-tests.md | 86 ++++++ docs/development.md | 4 + mkdocs.yml | 1 + pyproject.toml | 13 +- tests/ecosystem/conftest.py | 34 +++ tests/ecosystem/test_database_clients.py | 164 +++++++++++ tests/ecosystem/test_http_clients.py | 316 ++++++++++++++++++++++ tests/ecosystem/test_websocket_clients.py | 103 +++++++ uv.lock | 119 +++++--- 10 files changed, 865 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/async-ecosystem.yml create mode 100644 docs/async-ecosystem-tests.md create mode 100644 tests/ecosystem/conftest.py create mode 100644 tests/ecosystem/test_database_clients.py create mode 100644 tests/ecosystem/test_http_clients.py create mode 100644 tests/ecosystem/test_websocket_clients.py diff --git a/.github/workflows/async-ecosystem.yml b/.github/workflows/async-ecosystem.yml new file mode 100644 index 0000000..6eb88be --- /dev/null +++ b/.github/workflows/async-ecosystem.yml @@ -0,0 +1,67 @@ +name: Async ecosystem + +on: + pull_request: + paths: + - "Cargo.toml" + - "Cargo.lock" + - "build.rs" + - "src/**" + - "python/**" + - "tests/ecosystem/**" + - "tests/conftest.py" + - "scripts/generate_test_tls_certs.py" + - "scripts/run_python_tests.py" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/async-ecosystem.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: async-ecosystem-${{ github.ref }} + cancel-in-progress: true + +jobs: + lifecycle: + name: ecosystem / ${{ matrix.os }} / Python ${{ matrix.python }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + python: "3.10" + - os: ubuntu-latest + python: "3.14" + - os: macos-15 + python: "3.14" + - os: windows-latest + python: "3.14" + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b + with: + python-version: ${{ matrix.python }} + cache-suffix: ecosystem-${{ matrix.os }}-${{ matrix.python }} + - uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-09-25 + - uses: Swatinem/rust-cache@v2 + with: + cache-targets: false + - name: Install locked downstream dependencies and build rsloop + run: | + uv sync --locked --no-default-groups --group ecosystem --no-install-project + uv run --no-sync maturin develop --locked + uv run --no-sync python scripts/generate_test_tls_certs.py + - name: Record versions and require downstream imports + run: | + uv run --no-sync python -c "import aiohttp, aiosqlite, anyio, httpx, sqlalchemy, websockets; from importlib.metadata import version; print({p: version(p) for p in ('aiohttp', 'aiosqlite', 'anyio', 'httpx', 'sqlalchemy', 'websockets')})" + - name: Compare real application lifecycles across event loops + run: uv run --no-sync python scripts/run_python_tests.py tests/ecosystem -m ecosystem diff --git a/docs/async-ecosystem-tests.md b/docs/async-ecosystem-tests.md new file mode 100644 index 0000000..f1feacc --- /dev/null +++ b/docs/async-ecosystem-tests.md @@ -0,0 +1,86 @@ +# Async application lifecycle tests + +The downstream suite exercises actual clients, protocols, SQLite worker threads, +and event loops. It extends the existing framework smoke scripts beyond a single +successful request. HTTP clients run against loopback servers; databases use +temporary SQLite files. No public endpoint, database service, or credentials are +required at test time. + +## Selected projects and contracts + +The selection targets common integration surfaces in Python async applications: +HTTP pools, streaming protocols, cancellation scopes, database transactions, +and cross-thread result delivery. It builds on dependencies already used by the +repository's integration scripts and adds direct HTTPX coverage. + +| Project | Application scenario | Assertions | +| --- | --- | --- | +| HTTPX / HTTPcore / AnyIO | Cancel or time out an HTTP/HTTPS download while the only pool connection is occupied | Stream closes; the same client can open a replacement connection and complete another request | +| HTTPX | Pool exhaustion while another response is held open | PoolTimeout; no extra server connection; completing the first response permits keep-alive reuse | +| aiohttp | Cancel or time out a request queued behind a limit-one connector | The queued request never reaches the server; the active response remains readable; the connection is reused | +| HTTPX and aiohttp | Streaming upload and response with Unicode bytes split across arbitrary chunks | Exact payload preservation in both directions | +| HTTPX and aiohttp | Upstream closes before Content-Length bytes arrive | Library-specific truncation error; a subsequent request succeeds | +| websockets | Cancel recv while a fragmented message is incomplete, with and without compression | The next recv returns the entire message; subsequent binary messages remain ordered | +| websockets | Concurrent receivers and graceful close | Second receiver is rejected; the pending receiver observes normal closure | +| SQLAlchemy / aiosqlite | Cancel, time out, or raise after an insert in a transaction | Rollback removes the uncommitted row; the single pool slot is returned; the next transaction commits | +| SQLAlchemy / aiosqlite | Concurrent independent sessions stream rows through a two-connection pool | Complete ordered results; cursors and pool slots released | +| aiosqlite | Cancel a query while its SQLite worker is still executing | Late cross-thread completion does not corrupt the cancelled Future or the next query | + +Contract sources: + +- [HTTPX async streaming and closing](https://www.python-httpx.org/async/) +- [HTTPX timeouts](https://www.python-httpx.org/advanced/timeouts/) +- [aiohttp pool lifecycle](https://docs.aiohttp.org/en/stable/client_advanced.html) +- [aiohttp queue tracing](https://docs.aiohttp.org/en/stable/tracing_reference.html) +- [websockets recv cancellation and concurrency](https://websockets.readthedocs.io/en/stable/reference/asyncio/connection.html) +- [SQLAlchemy async sessions and connection disposal](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html) +- [aiosqlite worker-thread design](https://aiosqlite.omnilib.dev/en/stable/) + +Existing Redis/hiredis and AnyIO CI jobs remain useful complements. Framework +smokes for FastAPI, Starlette, Uvicorn and other packages remain under +tests/integration/packages. This suite does not establish PostgreSQL, Redis +cluster, HTTP/2, proxy, or production load compatibility. + +## Run the suite + +From the repository root: + +```bash +uv sync --locked --no-default-groups --group ecosystem --no-install-project +uv run --no-sync maturin develop --locked +uv run --no-sync python scripts/generate_test_tls_certs.py +uv run --no-sync python scripts/run_python_tests.py tests/ecosystem -m ecosystem +``` + +The ecosystem marker is excluded from the default core test run. Its dedicated +workflow installs the locked dependency group, requires all downstream imports, +prints dependency versions and runs the suite on Linux/Python 3.10 and 3.14, +macOS/Python 3.14, and Windows/Python 3.14. Every available platform compares +asyncio with rsloop; Unix also compares uvloop. Windows intentionally skips the +uvloop variants. Downstream native extension support is separate from rsloop's +core free-threaded matrix. + +Optional HTTP/database modules skip collection when their dependencies are +absent in a core-only environment. The dedicated workflow's import check makes +missing dependencies a job failure rather than an apparently successful suite. + +## Test design + +Events establish the interrupted state before cancellation. aiohttp's public +queue trace confirms a request is waiting for a pool slot. A WebSocket ping/pong +round trip accompanies an incomplete fragmented message. A threading event holds +a real SQLite worker until its caller has been cancelled. Timeout tests hold the +resource until the timeout occurs; they do not rely on the scheduler beating a +sleep of a particular duration. + +Network assertions operate on accumulated bytes, not TCP packet boundaries. +Handlers propagate their failures to the parent test. Sessions, responses, +cursors, sockets and engines are closed; task and loop-exception checks detect +orphaned work. Per-scenario deadlines bound waits. + +Initial Linux validation on CPython 3.14.7 passed all 66 parameterized cases +against asyncio, rsloop and uvloop with the locked dependencies: aiohttp 3.14.3, +HTTPX 0.28.1, HTTPcore 1.0.9, AnyIO 4.14.2, websockets 17.0.1, SQLAlchemy 2.0.52, +aiosqlite 0.22.1 and uvloop 0.23.0. These cases did not reproduce a new rsloop +defect; no production implementation change was needed. See the PR checks for +platform-specific execution results. diff --git a/docs/development.md b/docs/development.md index 8c82283..0e54144 100644 --- a/docs/development.md +++ b/docs/development.md @@ -253,6 +253,10 @@ exercise prolonged pause deterministically without depending on kernel buffer sizes. Weak references check that cancelled waits are released before resume. Tests also check for leaked tasks and unhandled loop exceptions. +For real HTTP, WebSocket and database cancellation/recovery scenarios, see +[Async application lifecycle tests](async-ecosystem-tests.md). The optional +ecosystem dependency group has a dedicated cross-platform CI matrix. + CI and `just test` use `scripts/run_python_tests.py`, which forwards pytest arguments and prints recurring stack dumps when tests stop making progress. For the same diagnostics locally, run `uv run python scripts/run_python_tests.py`. diff --git a/mkdocs.yml b/mkdocs.yml index f1d90a5..4ef874f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -17,4 +17,5 @@ nav: - How It Works: how-it-works.md - Project Structure: project-structure.md - Development: development.md + - Async Ecosystem Tests: async-ecosystem-tests.md - Verification: verification.md diff --git a/pyproject.toml b/pyproject.toml index 3f1dc21..919dbc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,16 @@ integration = [ "tortoise-orm>=1.0.0", "uvicorn>=0.33.0", ] +# Reproducible downstream lifecycle tests, separate from framework smoke scripts. +ecosystem = [ + { include-group = "test" }, + "maturin>=1.14.1", + "aiohttp>=3.14.3", + "httpx>=0.28.1", + "sqlalchemy[asyncio]>=2.0.38", + "aiosqlite>=0.20.0", + "uvloop>=0.23.0; sys_platform != 'win32'", +] # Core pytest dependencies, including on free-threaded Python. test = [ "pytest>=8.4", @@ -104,8 +114,9 @@ module-name = "rsloop._loop" python-source = "python" [tool.pytest.ini_options] -addopts = ["-m", "not tooling"] +addopts = ["-m", "not tooling and not ecosystem"] markers = [ + "ecosystem: downstream async library lifecycle contracts (requires ecosystem group)", "slow_network: real operating-system network behavior that may be slow", "stress: correctness tests with an optional high-repetition profile", "tooling: tests for repository benchmark and test infrastructure", diff --git a/tests/ecosystem/conftest.py b/tests/ecosystem/conftest.py new file mode 100644 index 0000000..880b4fd --- /dev/null +++ b/tests/ecosystem/conftest.py @@ -0,0 +1,34 @@ +"""Real event loops and bounded teardown for downstream library contracts.""" + +import asyncio + +import pytest +import rsloop + + +@pytest.fixture(params=["asyncio", "rsloop", "uvloop"]) +def loop(request): + if request.param == "uvloop": + factory = pytest.importorskip("uvloop").new_event_loop + elif request.param == "rsloop": + factory = rsloop.new_event_loop + else: + factory = asyncio.new_event_loop + event_loop = factory() + errors = [] + event_loop.set_exception_handler(lambda _, context: errors.append(context)) + yield event_loop + try: + pending = asyncio.all_tasks(event_loop) + for task in pending: + task.cancel() + if pending: + event_loop.run_until_complete( + asyncio.wait_for(asyncio.gather(*pending, return_exceptions=True), 5) + ) + event_loop.run_until_complete(event_loop.shutdown_asyncgens()) + event_loop.run_until_complete(event_loop.shutdown_default_executor()) + assert not pending, "test leaked tasks" + assert not errors, errors + finally: + event_loop.close() diff --git a/tests/ecosystem/test_database_clients.py b/tests/ecosystem/test_database_clients.py new file mode 100644 index 0000000..71bf62b --- /dev/null +++ b/tests/ecosystem/test_database_clients.py @@ -0,0 +1,164 @@ +"""Transaction ownership and pool recovery through SQLAlchemy and aiosqlite.""" + +import asyncio +from typing import Any, cast + +import pytest + +sqlalchemy = pytest.importorskip("sqlalchemy") +pytest.importorskip("aiosqlite") +sqlalchemy_asyncio = pytest.importorskip("sqlalchemy.ext.asyncio") +URL = sqlalchemy.URL +text = sqlalchemy.text +async_sessionmaker = sqlalchemy_asyncio.async_sessionmaker +create_async_engine = sqlalchemy_asyncio.create_async_engine + +pytestmark = pytest.mark.ecosystem + + +@pytest.mark.parametrize("interruption", ["cancel", "timeout", "exception"]) +def test_interrupted_transaction_rolls_back_and_returns_connection( + loop, tmp_path, interruption +): + async def main(): + engine = create_async_engine( + URL.create("sqlite+aiosqlite", database=str(tmp_path / "transactions.db")), + pool_size=1, + max_overflow=0, + pool_timeout=2, + ) + sessions = async_sessionmaker(engine) + inserted = asyncio.Event() + blocked = asyncio.Event() + + async def transaction(): + async with sessions.begin() as session: + await session.execute( + text("INSERT INTO messages VALUES (1, 'discard')") + ) + inserted.set() + if interruption == "exception": + raise ValueError("application failed after insert") + await blocked.wait() + + task = None + try: + async with engine.begin() as connection: + await connection.execute( + text("CREATE TABLE messages (id INTEGER PRIMARY KEY, body TEXT)") + ) + task = asyncio.create_task(transaction()) + await inserted.wait() + if interruption == "exception": + with pytest.raises(ValueError, match="application failed"): + await task + elif interruption == "cancel": + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + else: + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(task, 0) + assert cast(Any, engine.pool).checkedout() == 0 + async with sessions.begin() as session: + assert await session.scalar(text("SELECT count(*) FROM messages")) == 0 + await session.execute(text("INSERT INTO messages VALUES (2, 'commit')")) + async with sessions() as session: + result = await session.execute(text("SELECT id, body FROM messages")) + assert result.all() == [(2, "commit")] + finally: + if task is not None: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await asyncio.wait_for(engine.dispose(), 5) + + loop.run_until_complete(asyncio.wait_for(main(), 20)) + + +def test_concurrent_sessions_stream_results_and_release_pool(loop, tmp_path): + async def main(): + engine = create_async_engine( + URL.create("sqlite+aiosqlite", database=str(tmp_path / "readers.db")), + pool_size=2, + max_overflow=0, + pool_timeout=5, + ) + sessions = async_sessionmaker(engine) + tasks = [] + try: + async with engine.begin() as connection: + await connection.execute(text("CREATE TABLE numbers (value INTEGER)")) + await connection.execute( + text("INSERT INTO numbers VALUES (:value)"), + [{"value": value} for value in range(128)], + ) + + async def read(offset): + # Each concurrent task owns its own session and result stream. + async with sessions() as session: + result = await session.stream( + text( + "SELECT value FROM numbers WHERE value >= :offset ORDER BY value" + ), + {"offset": offset}, + execution_options={"yield_per": 7}, + ) + try: + values = [row[0] async for row in result] + assert values == list(range(offset, 128)) + finally: + await result.close() + return len(values) + + tasks = [asyncio.create_task(read(offset)) for offset in range(8)] + assert await asyncio.gather(*tasks) == [128 - offset for offset in range(8)] + assert cast(Any, engine.pool).checkedout() == 0 + finally: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + await asyncio.wait_for(engine.dispose(), 5) + + loop.run_until_complete(asyncio.wait_for(main(), 20)) + + +@pytest.mark.parametrize("interruption", ["cancel", "timeout"]) +def test_aiosqlite_cancel_inflight_thread_operation_then_reuse(loop, interruption): + import threading + + aiosqlite = pytest.importorskip("aiosqlite") + + async def main(): + entered = asyncio.Event() + release = threading.Event() + + def blocked_query(): + loop.call_soon_threadsafe(entered.set) + if not release.wait(5): + raise RuntimeError("test did not release the SQLite worker") + return 7 + + async with aiosqlite.connect(":memory:") as connection: + await connection.create_function("blocked_query", 0, blocked_query) + task = asyncio.ensure_future(connection.execute("SELECT blocked_query()")) + try: + await entered.wait() + assert not task.done() + if interruption == "cancel": + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + else: + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(task, 0) + release.set() + # The cancelled operation completes on its worker first. Its + # late result must not poison the next queued operation. + async with connection.execute("SELECT 42") as cursor: + assert await cursor.fetchone() == (42,) + finally: + release.set() + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + loop.run_until_complete(asyncio.wait_for(main(), 20)) diff --git a/tests/ecosystem/test_http_clients.py b/tests/ecosystem/test_http_clients.py new file mode 100644 index 0000000..e31b8ea --- /dev/null +++ b/tests/ecosystem/test_http_clients.py @@ -0,0 +1,316 @@ +"""HTTPX/AnyIO and aiohttp against a real, event-controlled HTTP server.""" + +import asyncio +import socket +import ssl +from contextlib import asynccontextmanager +from pathlib import Path + +import pytest + +aiohttp = pytest.importorskip("aiohttp") +httpx = pytest.importorskip("httpx") +web = pytest.importorskip("aiohttp.web") + +pytestmark = pytest.mark.ecosystem + + +def run(loop, coro): + return loop.run_until_complete(asyncio.wait_for(coro, 20)) + + +async def expect_prefix(chunks, expected): + received = bytearray() + while len(received) < len(expected): + received.extend(await chunks.__anext__()) + assert received == expected + + +@asynccontextmanager +async def server(handler, tls=False): + failures = [] + + async def checked(request): + try: + return await handler(request) + except Exception as error: + failures.append(error) + raise + + app = web.Application() + app.router.add_route("*", "/{path:.*}", checked) + runner = web.AppRunner(app, shutdown_timeout=2) + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.bind(("127.0.0.1", 0)) + listener.listen() + listener.setblocking(False) + host, port = listener.getsockname() + context = None + verify = True + if tls: + fixtures = Path(__file__).parents[1] / "fixtures" / "tls" + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(fixtures / "cert.pem", fixtures / "key.pem") + verify = ssl.create_default_context(cafile=str(fixtures / "ca-cert.pem")) + try: + await runner.setup() + await web.SockSite(runner, listener, ssl_context=context).start() + yield f"{'https' if tls else 'http'}://{host}:{port}", verify + finally: + await asyncio.wait_for(runner.cleanup(), 5) + listener.close() + assert not failures, failures + + +@pytest.mark.parametrize("tls", [False, True], ids=["http", "https"]) +@pytest.mark.parametrize("interruption", ["cancel", "read_timeout"]) +def test_httpx_interrupted_download_releases_pool(loop, tls, interruption): + async def main(): + release = asyncio.Event() + connections = [] + + async def handler(request): + connections.append(request.transport) + if request.path == "/ok": + return web.json_response({"ok": True}) + response = web.StreamResponse() + await response.prepare(request) + await response.write(b"prefix") + await release.wait() + return response + + async with ( + server(handler, tls) as (url, verify), + httpx.AsyncClient( + verify=verify, + trust_env=False, + limits=httpx.Limits(max_connections=1), + timeout=httpx.Timeout(5, read=1), + ) as client, + ): + try: + async with client.stream("GET", url + "/stream") as response: + assert response.status_code == 200 + chunks = response.aiter_bytes() + await expect_prefix(chunks, b"prefix") + if interruption == "read_timeout": + with pytest.raises(httpx.ReadTimeout): + await chunks.__anext__() + else: + entered = asyncio.Event() + + async def read_next(): + entered.set() + return await chunks.__anext__() + + task = asyncio.create_task(read_next()) + await entered.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert response.is_closed + recovered = await client.get(url + "/ok") + assert recovered.json() == {"ok": True} + assert len(connections) == 2 + assert connections[0] is not connections[1] + finally: + release.set() + + run(loop, main()) + + +def test_httpx_pool_timeout_then_keepalive_reuse(loop): + async def main(): + finish = asyncio.Event() + connections = [] + + async def handler(request): + connections.append(request.transport) + if request.path == "/ok": + return web.Response(body=b"ok") + response = web.StreamResponse() + await response.prepare(request) + await response.write(b"first") + await finish.wait() + await response.write(b"last") + return response + + async with ( + server(handler) as (url, _), + httpx.AsyncClient( + trust_env=False, limits=httpx.Limits(max_connections=1), timeout=5 + ) as client, + ): + try: + async with client.stream("GET", url + "/hold") as response: + chunks = response.aiter_bytes() + await expect_prefix(chunks, b"first") + with pytest.raises(httpx.PoolTimeout): + await client.get( + url + "/ok", timeout=httpx.Timeout(5, pool=0.02) + ) + assert len(connections) == 1 + finish.set() + assert b"".join([chunk async for chunk in chunks]) == b"last" + assert (await client.get(url + "/ok")).content == b"ok" + assert connections[0] is connections[1] + finally: + finish.set() + + run(loop, main()) + + +@pytest.mark.parametrize("interruption", ["cancel", "timeout"]) +def test_aiohttp_queued_request_cancellation_preserves_pool(loop, interruption): + async def main(): + finish = asyncio.Event() + queued = asyncio.Event() + connections = [] + + async def handler(request): + connections.append(request.transport) + if request.path == "/ok": + return web.Response(body=b"recovered") + response = web.StreamResponse() + await response.prepare(request) + await response.write(b"first") + await finish.wait() + await response.write(b"last") + return response + + async def on_queued(*_): + queued.set() + + trace = aiohttp.TraceConfig() + trace.on_connection_queued_start.append(on_queued) + async with ( + server(handler) as (url, _), + aiohttp.ClientSession( + connector=aiohttp.TCPConnector(limit=1), + trace_configs=[trace], + timeout=aiohttp.ClientTimeout(total=5), + ) as client, + ): + try: + async with client.get(url + "/hold") as response: + assert await response.content.readexactly(5) == b"first" + task = asyncio.create_task(client.get(url + "/ok")) + await queued.wait() + assert not task.done() + if interruption == "cancel": + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + else: + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(task, 0) + assert len(connections) == 1 + finish.set() + assert await response.read() == b"last" + async with client.get(url + "/ok") as response: + assert await response.read() == b"recovered" + assert connections[0] is connections[1] + finally: + finish.set() + + run(loop, main()) + + +@pytest.mark.parametrize("client_kind", ["httpx", "aiohttp"]) +def test_chunked_upload_and_response_preserve_bytes(loop, client_kind): + async def main(): + seen = asyncio.Event() + expected = "Привіт, async 🌍!".encode() * 4096 + + async def handler(request): + received = bytearray() + async for chunk in request.content.iter_chunked(3071): + received.extend(chunk) + seen.set() + assert received == expected + response = web.StreamResponse() + await response.prepare(request) + for offset in range(0, len(received), 4093): + await response.write(received[offset : offset + 4093]) + return response + + async def upload(): + yield expected[:13] + # Require the server to consume a prefix before the next chunk. + await seen.wait() + for offset in range(13, len(expected), 1021): + yield expected[offset : offset + 1021] + + async with server(handler) as (url, _): + if client_kind == "httpx": + async with ( + httpx.AsyncClient(trust_env=False, timeout=5) as client, + client.stream("POST", url, content=upload()) as response, + ): + assert response.status_code == 200 + received = b"".join( + [chunk async for chunk in response.aiter_bytes()] + ) + else: + async with ( + aiohttp.ClientSession() as client, + client.post(url, data=upload()) as response, + ): + assert response.status == 200 + received = b"".join( + [chunk async for chunk in response.content.iter_any()] + ) + assert received == expected + + run(loop, main()) + + +@pytest.mark.parametrize("client_kind", ["httpx", "aiohttp"]) +def test_truncated_response_reports_error_and_client_recovers(loop, client_kind): + async def main(): + handlers = [] + failures = [] + + async def handler(reader, writer): + handlers.append(asyncio.current_task()) + try: + headers = await reader.readuntil(b"\r\n\r\n") + body = b"bad" if headers.startswith(b"GET /truncated ") else b"ok" + length = 10 if body == b"bad" else len(body) + writer.write( + f"HTTP/1.1 200 OK\r\nContent-Length: {length}\r\n" + "Connection: close\r\n\r\n".encode() + + body + ) + await writer.drain() + except Exception as error: # noqa: BLE001 - asserted by the parent test + failures.append(error) + finally: + writer.close() + await asyncio.wait_for(writer.wait_closed(), 5) + + listener = await asyncio.start_server(handler, "127.0.0.1", 0) + url = f"http://127.0.0.1:{listener.sockets[0].getsockname()[1]}" + try: + if client_kind == "httpx": + async with httpx.AsyncClient(trust_env=False, timeout=5) as client: + with pytest.raises(httpx.RemoteProtocolError): + await client.get(url + "/truncated") + assert (await client.get(url + "/ok")).content == b"ok" + else: + async with aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=5) + ) as client: + with pytest.raises(aiohttp.ClientPayloadError): + async with client.get(url + "/truncated") as response: + await response.read() + async with client.get(url + "/ok") as response: + assert await response.read() == b"ok" + finally: + listener.close() + await listener.wait_closed() + if handlers: + await asyncio.wait_for(asyncio.gather(*handlers), 5) + assert not failures, failures + + run(loop, main()) diff --git a/tests/ecosystem/test_websocket_clients.py b/tests/ecosystem/test_websocket_clients.py new file mode 100644 index 0000000..b0778b5 --- /dev/null +++ b/tests/ecosystem/test_websocket_clients.py @@ -0,0 +1,103 @@ +"""Documented websockets recv cancellation contracts over real TCP.""" + +import asyncio + +import pytest +from websockets.asyncio.client import connect +from websockets.asyncio.server import serve +from websockets.exceptions import ConcurrencyError, ConnectionClosedOK + +pytestmark = pytest.mark.ecosystem + + +@pytest.mark.parametrize("compression", [None, "deflate"]) +@pytest.mark.parametrize("interruption", ["cancel", "timeout"]) +def test_fragmented_receive_survives_cancellation(loop, compression, interruption): + async def main(): + first_fragment = asyncio.Event() + finish = asyncio.Event() + failures = [] + fragments = ["Привіт ", "🌍 " * 8192, "кінець"] + messages = [b"one", b"two", b"three"] + + async def handler(websocket): + async def fragmented(): + yield fragments[0] + first_fragment.set() + await finish.wait() + for fragment in fragments[1:]: + yield fragment + + try: + await websocket.send(fragmented()) + for message in messages: + await websocket.send(message) + await websocket.wait_closed() + except Exception as error: + failures.append(error) + raise + + async with serve( + handler, "127.0.0.1", 0, compression=compression, close_timeout=1 + ) as server: + port = server.sockets[0].getsockname()[1] + async with connect( + f"ws://127.0.0.1:{port}", + compression=compression, + max_queue=1, + close_timeout=1, + ) as client: + pending = asyncio.create_task(client.recv()) + try: + await first_fragment.wait() + # The ping/pong round trip lets the incomplete frame stream + # reach the client before cancellation, without a timed sleep. + pong = await client.ping(b"fragment-arrived") + await pong + assert not pending.done() + if interruption == "cancel": + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + else: + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(pending, 0) + finish.set() + assert await client.recv() == "".join(fragments) + assert [await client.recv() for _ in messages] == messages + finally: + finish.set() + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) + assert not failures, failures + + loop.run_until_complete(asyncio.wait_for(main(), 20)) + + +def test_concurrent_receive_rejected_and_close_wakes_reader(loop): + async def main(): + async def handler(websocket): + await websocket.wait_closed() + + async with serve(handler, "127.0.0.1", 0, close_timeout=1) as server: + port = server.sockets[0].getsockname()[1] + async with connect(f"ws://127.0.0.1:{port}", close_timeout=1) as client: + entered = asyncio.Event() + + async def receive(): + entered.set() + return await client.recv() + + pending = asyncio.create_task(receive()) + try: + await entered.wait() + with pytest.raises(ConcurrencyError): + await client.recv() + await client.close(code=1000, reason="finished") + with pytest.raises(ConnectionClosedOK): + await pending + finally: + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) + + loop.run_until_complete(asyncio.wait_for(main(), 20)) diff --git a/uv.lock b/uv.lock index fc7acdd..0c38b4e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'win32'", @@ -2899,6 +2899,19 @@ dev = [ { name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "websockets", version = "17.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] +ecosystem = [ + { name = "aiohttp" }, + { name = "aiosqlite" }, + { name = "httpx" }, + { name = "maturin" }, + { name = "pytest" }, + { name = "pytest-mock" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "typing-extensions" }, + { name = "uvloop", marker = "sys_platform != 'win32'" }, + { name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "websockets", version = "17.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] integration = [ { name = "aiohttp" }, { name = "aiosqlite" }, @@ -2949,6 +2962,18 @@ dev = [ { name = "typing-extensions", specifier = ">=4.0" }, { name = "websockets", specifier = ">=13.0" }, ] +ecosystem = [ + { name = "aiohttp", specifier = ">=3.14.3" }, + { name = "aiosqlite", specifier = ">=0.20.0" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "maturin", specifier = ">=1.14.1" }, + { name = "pytest", specifier = ">=8.4" }, + { name = "pytest-mock", specifier = ">=3.14" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.38" }, + { name = "typing-extensions", specifier = ">=4.0" }, + { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.23.0" }, + { name = "websockets", specifier = ">=13.0" }, +] integration = [ { name = "aiohttp", specifier = ">=3.14.3" }, { name = "aiosqlite", specifier = ">=0.20.0" }, @@ -3518,46 +3543,58 @@ wheels = [ [[package]] name = "uvloop" -version = "0.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, - { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, - { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, - { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, - { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, - { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, - { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, - { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, - { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, - { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, - { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, - { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, - { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, - { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, - { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, - { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, - { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, - { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, - { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, - { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, - { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, - { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, - { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, - { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, - { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/aa/a67389d92dc118bb6b48cb57b08bf6f24925a07e05de196e4b998c339017/uvloop-0.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ce17bc317d089f361b33521654c13e30eacfd3d2034fd34e613ca9c51c969686", size = 1420655, upload-time = "2026-10-01T03:15:21.22Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/749d8bad691e6036f83d7c7e3cb34306261e01de847ce4ce46eb7aec5240/uvloop-0.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:53c2c5d7e2024e46776c2d90e6c637d01102126b61aaf5faa5edaf05f8b5722a", size = 780765, upload-time = "2026-10-01T03:15:22.842Z" }, + { url = "https://files.pythonhosted.org/packages/bc/44/a4b7bea44d55c882e23fc858eebed9e157486650cdbecdb951577e89362f/uvloop-0.23.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:42feced24b9b44b856c633eafb5cc5dec354972da55ce77598db6844c054bc7c", size = 3795900, upload-time = "2026-10-01T03:15:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/76/4a/488d9ee6eb87899273d84ebeaf7023c551ff8f8d44f7e7c0f78d06b6da25/uvloop-0.23.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bf08e4b6362dd1c08623bbfa2d061e8bac0f1da8fc2007062cfe1dc360a49fa", size = 3850999, upload-time = "2026-10-01T03:15:27.308Z" }, + { url = "https://files.pythonhosted.org/packages/fc/51/6146339b0a4e0f880ed1abd98517b21a6021ac0988cbc83c7339d7ee346f/uvloop-0.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4bb7f5d0b62b5afaaaea2b7b60d508921c24b0fe39c22c1438bec1811ffe10ec", size = 3655020, upload-time = "2026-10-01T03:15:28.908Z" }, + { url = "https://files.pythonhosted.org/packages/7a/76/c2576407efee20fdfbf08ad35122ec9b2eb439a9090016e7f025c41259ab/uvloop-0.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0305871ac712f54b62af73f943dbf21ae3ce80a44bc0f0151424484affa85645", size = 3758530, upload-time = "2026-10-01T03:15:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b1/948067eab45d5307f04b34e50eb7bd1f7352aee866fa5f0706b061ddacf0/uvloop-0.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:24c58ae4a83e93a04c504bcc678125e36a0bfc44af928ad69444880c60f187a5", size = 1415276, upload-time = "2026-10-01T03:15:32.634Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6f/ee3ee84c5d27f2f0a47ae8b67a6adeacf9841b193c0e07412a1403586ce2/uvloop-0.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0efdd55bddbd36bb2fcb842d64c0d5f6407c6958c68088cc25df8c09edc5b5fd", size = 779533, upload-time = "2026-10-01T03:15:34.062Z" }, + { url = "https://files.pythonhosted.org/packages/25/0d/b5f69dae3736d96a8753c6ecd32d676ecd212be7ba3252e9c379ad9cc05c/uvloop-0.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fcd721113260ffb5e38bf14a8725b17d431f34209f7d1c7005b667946e630b3", size = 3896377, upload-time = "2026-10-01T03:15:35.816Z" }, + { url = "https://files.pythonhosted.org/packages/16/fd/8cbf6124607863399008ae4b0d2bb50c22ed83526deec28dca08d635eb6d/uvloop-0.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab17b3a8aa754be0de0e397f7b95f13b14e56f077a4c6ae295e3d4afd199b325", size = 3956355, upload-time = "2026-10-01T03:15:37.688Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7a/b73007866e7198519067a1f1afc343b4973ae924d2b7afcea67c44320a98/uvloop-0.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:80cac5cb90ed7b9b72a217a1d6982b15b829cdbd0ee6bc19b93e3a9e47fb0ac9", size = 3755618, upload-time = "2026-10-01T03:15:39.27Z" }, + { url = "https://files.pythonhosted.org/packages/3c/28/e50816f1ce38b97b28d62bc4adf7c82c33b7c68fa902e41a39adc8a3d189/uvloop-0.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:93087a845cdfb35753e539354ac9551bdd2ff528c202a98df0ae46e852bcf021", size = 3863192, upload-time = "2026-10-01T03:15:40.882Z" }, + { url = "https://files.pythonhosted.org/packages/05/98/04e766a6de99e6f7f955ecb7829e8d5a557de3427cb85be2236de54dda0c/uvloop-0.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:93935ab27b6eaef4c3e5489aebc84284f0644592f7ab516df60ee1b27eaf5eb3", size = 1393055, upload-time = "2026-10-01T03:15:42.526Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/499e7b863a848ede009539bce39806b66205da5f8779354228e785601144/uvloop-0.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4448e9124537620f9c25d004c227bb5104440b58955c19bbd312d910af919a63", size = 768909, upload-time = "2026-10-01T03:15:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/3d/95/a880f8ce3b87ac5b307c354e8ee480be4658d24bf01f87921d57e3530b4a/uvloop-0.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7548ede3ee908cfabc0d068106e303a9a2d811af959cdf6ab85676344cedcda", size = 4419106, upload-time = "2026-10-01T03:15:45.551Z" }, + { url = "https://files.pythonhosted.org/packages/51/27/c1d2f9fa977f8f42ea294604166df10e0027e6dc6cd17f85ede386c9bf36/uvloop-0.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:090865d8ce7a03986755a3ce711b7dd0d4b44eb14ab74368b717f3fad1180208", size = 4532597, upload-time = "2026-10-01T03:15:47.258Z" }, + { url = "https://files.pythonhosted.org/packages/42/dd/2cb6a2c8a30ca55c07a882dd4ae4ceae0fa7d8c15b25b3b7cb9a4b6cf4ca/uvloop-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd6f2f81c7b9da99d301c0b16b82044e76fe887086e42e1590ecf520b94dbdac", size = 4230048, upload-time = "2026-10-01T03:15:49.119Z" }, + { url = "https://files.pythonhosted.org/packages/f4/52/29989cbaa4022dc4ef35c1dd60a4ab989e4c2065f341ed483ae71d2bd950/uvloop-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a6ac96da66c35bf789bdcde78a88dc7d56b7907d8379648c54adc1c61594575d", size = 4394152, upload-time = "2026-10-01T03:15:50.829Z" }, + { url = "https://files.pythonhosted.org/packages/5f/83/eb980d64e6dd5da46d4dc35755fa6afd6b5b47141437cf89615f1117c5a6/uvloop-0.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65", size = 1412726, upload-time = "2026-10-01T03:15:52.49Z" }, + { url = "https://files.pythonhosted.org/packages/04/c1/02a725e7698134c647904bdee6589e2be14a0e7fc9942c74f86e2b90d48b/uvloop-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb", size = 779071, upload-time = "2026-10-01T03:15:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1d/cde53c79e8c01884ad1cdca8e407e086d523362cfe4139e2c2a8dde27304/uvloop-0.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5", size = 4395323, upload-time = "2026-10-01T03:15:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/98/54/b12915bebbf99d7ae0796211e7f5977b95f069830dca45dc1a346d84125d/uvloop-0.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb", size = 4480449, upload-time = "2026-10-01T03:15:57.362Z" }, + { url = "https://files.pythonhosted.org/packages/f7/8e/da6de68c31549a052a105fc76f5a9a204f6df22cb0909440aa4dbb06f9a2/uvloop-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848", size = 4219177, upload-time = "2026-10-01T03:15:59.351Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c3/1b53c6a89dc9c9d5cb75eb9a0b891ad69b32e1421ad3aa01617a9cbdcc78/uvloop-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f", size = 4346132, upload-time = "2026-10-01T03:16:01.064Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a4/00e85345871c59c834a23c136c1771205856028ecc8ba940b3951178e59b/uvloop-0.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b90397a50ad6332ed3e459c648ac20d182cce24a557354363ad85fc9ea4a17cd", size = 1421363, upload-time = "2026-10-01T03:16:02.599Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a9/e5f0f3cfde30af3ec32eba8ec07bccdba2b5116afbd1ecc53edfeb0a0790/uvloop-0.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:be53e1d5f83de43dc175c87612ecc128d444b38e5c56cb3f807f5a73d6887476", size = 785177, upload-time = "2026-10-01T03:16:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/9e/79/9ddf78f8cd75a15c14a09a57f59c587b8cd9d82802c5c8368b9c3ebefa0b/uvloop-0.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b3cbc4f96ddfa1fb88a78a69dd851369825b7816d9702eee8c4461505ba172e", size = 4381060, upload-time = "2026-10-01T03:16:05.642Z" }, + { url = "https://files.pythonhosted.org/packages/1e/20/57d63c44d32326878fcad5c63854afc9deb394ed95673c1b1a429178c79d/uvloop-0.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31e0cf90bc8fd88784f6802cdba968a51fb1aec1cc3feec74d862b2d371d1330", size = 4418891, upload-time = "2026-10-01T03:16:07.326Z" }, + { url = "https://files.pythonhosted.org/packages/12/c5/0795abecda2cc3dfe41033f880a32a9ff103be4e6b177ac736833c153a0e/uvloop-0.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8ed556fcc87a4091cf61587ef172fa104323dc89ecc085a618ba7ff8629a8f", size = 4214811, upload-time = "2026-10-01T03:16:09.13Z" }, + { url = "https://files.pythonhosted.org/packages/20/18/9010dacd5221eec1bd79a4a83ac68f3db6a42d7bb657f7b640c4838ca6b6/uvloop-0.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3fbfe82829d8e381426a289b87e59e585278728361db9ce975b88b51f64f410", size = 4294876, upload-time = "2026-10-01T03:16:10.875Z" }, + { url = "https://files.pythonhosted.org/packages/b1/08/f6384a03c771d00067cba4f542a69b2fc1a982e9fd78b357c2f788678d72/uvloop-0.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7e35c9bc977760981693e1a7a51493b58ee5a501f9ebb1e547565ee40b6c6208", size = 1494811, upload-time = "2026-10-01T03:16:12.399Z" }, + { url = "https://files.pythonhosted.org/packages/ac/01/756a4fb24a449f313cf4a153eb0c6210b49cfe5539255ec9fb1e17d2c4ef/uvloop-0.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5bb9be71d9ee39b4359b832f9569518ec9bc08704194034e79e4958e6bc4d46d", size = 819396, upload-time = "2026-10-01T03:16:14.094Z" }, + { url = "https://files.pythonhosted.org/packages/3e/45/e314b0c600b14f53dad3a3c2d7a922a249a88225fd727652b53e1854b9dd/uvloop-0.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e84575f11873c109cf3962ad0bdf679094466184125f4cadcc41a73febff41f", size = 4734966, upload-time = "2026-10-01T03:16:15.815Z" }, + { url = "https://files.pythonhosted.org/packages/66/0d/8686a7f0b1b2d55ebd770ba21f8e0e4ffa0cde5ab738f43ffb8264499052/uvloop-0.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbbdb8fcd5e7062e546eec1ac78c28bb21ae7df54c18f8e4b06e15a18d661a49", size = 4584963, upload-time = "2026-10-01T03:16:18.198Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/034a2d47e435ac02357c42956246887167bdc0357bdd6ad31c5f6d94497b/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76345f51367fb1f23e08605c6efb18374f669be5b223658fbab6b17627950507", size = 4421388, upload-time = "2026-10-01T03:16:19.953Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/131f4b583e6b4b715c404a66b51c812d701db20f25c9018b188a2b00062c/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7ef4701a96553514b2688e342ef1bf2beae6cfd172d89a76c768292aabf405", size = 4402414, upload-time = "2026-10-01T03:16:21.716Z" }, + { url = "https://files.pythonhosted.org/packages/58/3d/ee11f4718ea1280595c67ed25c83d4c92115dc100bbdfd192d3ed9339168/uvloop-0.23.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f1341c6abcee1c31277cfe28d34e46196f2143ec3d755e6efe7452126e1f626d", size = 1418095, upload-time = "2026-10-01T03:16:23.241Z" }, + { url = "https://files.pythonhosted.org/packages/f8/0c/7ca516a0671418517d79a09d3ff2ccbb44af94c75711afa6e4cf58aa6f65/uvloop-0.23.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e095f9e105af76593b4c183bb0bcbdae64bd913a59ec595732dc108b48730ab5", size = 784837, upload-time = "2026-10-01T03:16:24.666Z" }, + { url = "https://files.pythonhosted.org/packages/35/95/75d4e28e596d505b7ae11de517646b4ca3d369fb8537ba755410380da11a/uvloop-0.23.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f673d835bdb1a60229cc3609a113fd2c9ce3f4a3c75ad4eaed111180c00199d2", size = 4380276, upload-time = "2026-10-01T03:16:26.389Z" }, + { url = "https://files.pythonhosted.org/packages/10/99/68daf827ad62efaf4667d1f3fda127046d42161178396bdd93aab3684082/uvloop-0.23.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f23f403a273900d57de6ee5ca0614c650f7f58563065dad1a4744498960e53", size = 4451496, upload-time = "2026-10-01T03:16:28.364Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/f67e696ee688f426a96f99099bae26fec14a1d0fa75dccdd6518ee267c0c/uvloop-0.23.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cbe8d03d4efcccdb7fcedecbaa1e1fa02913eaf3a74cb933634a6bc6d2ea9e2a", size = 4212541, upload-time = "2026-10-01T03:16:30.014Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6a/c8c436a9d7453297b4be70bdf6a9f9fc9400da45e0059ddf7b28ab63f4c7/uvloop-0.23.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4f1798f56c6f4ba5ac11fa2869e5717926e4470d97a1dd42b4f59219d43b5027", size = 4319377, upload-time = "2026-10-01T03:16:31.705Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2c/8fc15a03489299aab8a6212dfe0f137dc39836f915c87f7fd9d9ddd814de/uvloop-0.23.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:098a85e1393ef5202767b7e5fb41a32cd8bd81e6ee4af364c179801c4aa3f6d4", size = 1493428, upload-time = "2026-10-01T03:16:33.859Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7c/05e4a210790229607f71460fcb2ed4a2c7bc72668d8a928ce577c22e38f8/uvloop-0.23.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:5a2bbad3a63007f7e9524d4903ba04fee252557c2acd86f9a3d4f91786695254", size = 818115, upload-time = "2026-10-01T03:16:35.45Z" }, + { url = "https://files.pythonhosted.org/packages/65/14/a40b11c6c024213803b13955664a15754c72f64c873a33d986b26ec9ff5b/uvloop-0.23.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a08875543bbd4519faf30497506c9cda8a48470467ffdf967c7313c7a5981a8", size = 4734149, upload-time = "2026-10-01T03:16:37.025Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/f421a077712c1e87603bfec62744c3cd3a2f4b47378025db3d740df9af0d/uvloop-0.23.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12634f15e6625f78b3f2922f91404c4d7173487eba11746764153f556e9852dc", size = 4661763, upload-time = "2026-10-01T03:16:38.719Z" }, + { url = "https://files.pythonhosted.org/packages/f5/62/25dcaa6b7e7b48f82ce633854ce96597ab768f9650931f4f86c572de392c/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:378188efbb1524f2219d05246a3e1e5907217848d2882144dff59585f1b81d55", size = 4421324, upload-time = "2026-10-01T03:16:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/05/46/04628239b43dcef703af314202a3307d6060918e2d76aa86c5b1188f5551/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4b8e207c67d207a8608fec57e116511030af3495dc0109b8c333cf9cb412b16f", size = 4462501, upload-time = "2026-10-01T03:16:42.359Z" }, ] [[package]]