From 77ba2cec2cbdc7d2f23b9cc3398448a888957739 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Wed, 21 Oct 2026 16:11:36 -0700 Subject: [PATCH 1/7] Support streamable runner payloads (#80) Accept dicts, text, bytes, binary files, and async byte streams with an optional content type. Send omitted and None payloads as empty bodies. Add StreamPayload(source=...) for streaming data sources. To accommodate 402 (payment) and other error response codes without consuming the payload, the client sends an `Expect: 100-continue` and waits for the 100 before transmitting the body. This is something that most HTTP servers do by default already, including golang's. Bounded (non-streaming) inputs do not wait for the 100. In the case that there is still an error after payment, streaming input is buffered for replay during runner failover (optional, but enabled by default). Seekable files are rewound instead of buffered. This is API-compatible with existing callers, with a few tweaks: * `None` payloads now don't send a body, instead of `{}` * `stream=True` (response streaming) now honors `timeout` instead of using a fixed 10s Also remove an unused `Label` field from runner registration. --- CHANGELOG.md | 5 + README | 4 +- docs/runner-payloads.md | 193 ++++++++ examples/text/runners.toml | 1 - pyproject.toml | 1 + src/livepeer_gateway/__init__.py | 3 + src/livepeer_gateway/_http_continue.py | 54 +++ src/livepeer_gateway/http.py | 139 ++++-- src/livepeer_gateway/live_runner.py | 294 ++++++------ src/livepeer_gateway/runner_payload.py | 272 +++++++++++ src/livepeer_gateway/selection.py | 116 +++-- tests/test_call_runner_uploads.py | 639 +++++++++++++++++++++++++ tests/test_live_runner.py | 2 +- tests/test_runner_payload.py | 258 ++++++++++ tests/test_selection.py | 70 ++- 15 files changed, 1833 insertions(+), 218 deletions(-) create mode 100644 docs/runner-payloads.md create mode 100644 src/livepeer_gateway/_http_continue.py create mode 100644 src/livepeer_gateway/runner_payload.py create mode 100644 tests/test_call_runner_uploads.py create mode 100644 tests/test_runner_payload.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a067f9f..6d30d86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- Flexible request body types on call_runner and selectors. + ### Changed - `discover_orchestrators` is now asynchronous and must be awaited, including @@ -13,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Removed +- The unused `label` option from `register_runner` and `LiveRunnerRegistration`. - Synchronous HTTP helpers `request_json_sync`, `get_json_sync`, and `post_json_sync`. All HTTP requests now use the asynchronous transport. - TLS certificate-verification bypasses in HTTP requests, streaming responses, diff --git a/README b/README index 6e32fcf..312f2fe 100644 --- a/README +++ b/README @@ -80,8 +80,8 @@ asyncio.run(main("")) ``` For single-shot apps, use `runner_selector()` and `await cursor.next()`, or -`call_runner()` for a known runner URL. Paid runners support `hour`, `seconds`, -and `fixed` pricing through a remote signer. +`call_runner()` for a known runner URL. See [Calling runners](docs/runner-payloads.md) +for request options, streaming uploads and responses, payment, and retries. Read demuxed packets without decoding, using the output channel URL returned by the app: diff --git a/docs/runner-payloads.md b/docs/runner-payloads.md new file mode 100644 index 0000000..347f831 --- /dev/null +++ b/docs/runner-payloads.md @@ -0,0 +1,193 @@ +# Calling runners + +Use `call_runner()` for a known runner URL, or a [selector](#selectors) to discover +one. By default, calls return a complete `LiveRunnerCallResult`: JSON objects in +`result.data`, other formats as bytes in `result.content`, and the response media +type in `result.content_type`. + +## `call_runner` parameters + +All parameters except `runner_url` must be passed by keyword. + +| Parameter | Default | Meaning | +|---|---|---| +| `runner_url` | `""` | Runner endpoint URL. Required unless supplied by `runner`. | +| `runner` | `None` | A `LiveRunnerInstance` with URL and app/pricing metadata. An explicit `runner_url` takes precedence. | +| `payload` | `None` | Request body; see [body types](#body-types). | +| `content_type` | `None` | Override the request's `Content-Type` header without changing its encoding. | +| `method` | `"POST"` | HTTP request method. | +| `signer_url` | `None` | Remote signer URL for automatic payment. Required for paid calls. | +| `signer_headers` | `None` | Dictionary of additional HTTP headers for signer requests. | +| `payment_unit` | `None` | `"hour"`, `"seconds"`, or `"fixed"`. Defaults to the runner's pricing unit; an explicit value must agree with it. With neither, payment uses ongoing funding. | +| `timeout` | `5.0` | Seconds per request attempt; see [timeouts](#timeouts) for streaming behavior. | +| `max_payment_challenge_retries` | `3` | Payment retry budget, with additional allowance to refresh expired payment parameters. | +| `stream` | `False` | Set to `True` for an incremental [response](#streaming-responses) as a `LiveRunnerCallStream`. | + +### Body types + +`dict` values default to `application/json`; other bodies default to +`application/octet-stream`. Set `content_type` for another media type, such as +`"audio/wav"`. + +| `payload` | Sent as | +|---|---| +| `dict` | JSON. | +| `str` | UTF-8. | +| `bytes`, `bytearray`, or `memoryview` | Raw bytes. | +| `BinaryIO` or `AsyncIterable[bytes]` | Incremental upload using `StreamPayload` defaults. | +| `StreamPayload` | Incremental upload with explicit settings. | +| Omitted or `None` | Empty body. | + +## Streaming uploads + +`StreamPayload` is an immutable configuration for a file or async iterable +payload. Bare files and async iterables use the same defaults. + +| Field | Default | Meaning | +|---|---|---| +| `source` | Required | A binary file with a synchronous `read()` method, or an async iterable yielding `bytes`. | +| `expect100` | `True` | Send headers first and wait for a continuation response before reading or sending input; see [when input is read](#when-input-is-read). Set to `False` for endpoints without this support. | +| `retry_buffer` | `True` | Retain input from non-seekable files and async iterables so it can be sent again on a retry. | +| `retry_buffer_memory_limit` | `25_000_000` | Bytes of retained input allowed in memory before using temporary disk storage (25 MB). | +| `retry_buffer_limit` | `250_000_000` | Total bytes of input retained for retries (250 MB). | + +Limits use decimal bytes and require +`0 < retry_buffer_memory_limit <= retry_buffer_limit`. They bound the retained +copy, not upload size or memory used by your code. Exceeding the total limit or a +storage failure lets the current upload continue but prevents later retries. +Seekable files rewind for retries and do not use these limits. + +### When input is read + +Creating a `StreamPayload` or discovering runners does not read the input. +Reading begins during `call_runner()` or `cursor.next()`: with `expect100=True`, +headers are sent first so the runner can request payment or reject the call +before consuming input. The SDK uses aiohttp's native handling: `100 Continue` +normally starts the upload, but other informational `1xx` responses except +`101 Switching Protocols`, such as `102 Processing` or `103 Early Hints`, can +also start it (unexpected informational responses like these are rare in +practice). If waiting for this response times out, the request fails before +reading or sending the body. + +With `expect100=False`, reading starts after the headers are sent. In either +case, input is read as it uploads, starting at the file's current position. +An early response or failure can leave it partly consumed. + +You are responsible for closing files and managing generator resources. Keep +inputs usable until the call finishes or the response/selector context closes, +and do not read them elsewhere during the operation. The examples nest contexts +to ensure this. + +For an endpoint without `100 Continue` support: + +```python +from livepeer_gateway import StreamPayload, call_runner + + +async def upload(runner_url, path): + with open(path, "rb") as audio: + return await call_runner( + runner_url, + payload=StreamPayload(source=audio, expect100=False), + content_type="audio/wav", + timeout=60, + ) +``` + +## Retries + +A retry sends the same body in a new request: + +- After `402 Payment Required`, `call_runner()` obtains payment through the + configured signer and submits the request again. +- If a runner call fails, a selector's `next()` tries another candidate. + +For streamed uploads, `call_runner()` does not automatically resend after +connection failures or follow redirects. A selector can try another candidate. + +Failover can repeat work if the original runner executed it but its response was +lost. + +Dictionaries, strings, and byte values can be sent again directly. Streamed input +must still be usable: + +- Seekable files rewind to their position at the start of the operation, even + with `retry_buffer=False`. +- Non-seekable files and async iterables can resend the full body, including + after a partial upload, if retry buffering remains available. +- With `retry_buffer=False`, non-seekable input can only be retried before reading + starts—for example, after a payment challenge received before upload permission. + +Retry eligibility follows actual input consumption. If an informational response +starts an unbuffered upload before a payment challenge, the consumed input can +prevent an automatic retry. + +A required retry raises `LivepeerGatewayError` if the body cannot be sent again. +This retry support lasts for one call or selector cursor. For a separate call, +supply a fresh input or rewind the file yourself. + +## Streaming responses + +`stream=True` controls the **response**, independently of the upload type. It +returns after payment challenges are handled and final response headers arrive; +the upload may still be running. Read bytes with `aiter_bytes()` or decoded text +lines with `aiter_lines()`. JSON is not parsed automatically. + +For an app that accepts audio and returns text incrementally: + +```python +from livepeer_gateway import call_runner + + +async def transcribe(runner_url, path): + with open(path, "rb") as audio: + async with await call_runner( + runner_url, + payload=audio, + content_type="audio/wav", + stream=True, + ) as response: + async for line in response.aiter_lines(): + print(line) +``` + +Use the response as an async context manager or call `await response.aclose()`. +Closing it closes the connection and stops any unfinished upload and ongoing +payment funding. Errors while reading a returned response are not retried. + +### Timeouts + +| Response mode | What `timeout` bounds per attempt | +|---|---| +| `stream=False` | The whole HTTP request, including upload and response reading. Increase it for longer uploads or jobs. | +| `stream=True` | Connection establishment and, with `expect100=True`, [waiting for a continuation response](#when-input-is-read). Uploads and response reading have no deadline after that response; with `expect100=False`, only connection establishment is bounded. | + +Retries can make the overall operation take longer. Use `asyncio.timeout()` +around the operation if you need an overall deadline. + +## Selectors + +`runner_selector()` discovers candidates and returns a cursor. Pass input as +`body` instead of `payload`, with the same optional `content_type`. Each +`cursor.next()` returns the next successful result; another call sends the same +body to subsequent candidates. Responses are always read in full. +Await each `cursor.next()` before calling it again. + +Use an async context manager or `await cursor.aclose()` when finished with the +cursor. Closing it ends selection but leaves your file open. + +```python +from livepeer_gateway import runner_selector + + +async def upload_to_available_runner(path, discovery_url): + with open(path, "rb") as audio: + async with await runner_selector( + discovery_url=discovery_url, + app="example/transcribe", + body=audio, + content_type="audio/wav", + timeout=60, + ) as cursor: + return await cursor.next() +``` diff --git a/examples/text/runners.toml b/examples/text/runners.toml index 0ebb404..90e623c 100644 --- a/examples/text/runners.toml +++ b/examples/text/runners.toml @@ -1,6 +1,5 @@ [[Runners]] ID = "story-runner" -Label = "story-runner" App = "livepeer/read-story" RunnerURL = "http://127.0.0.1:8990" HealthURL = "http://127.0.0.1:8990/healthz" diff --git a/pyproject.toml b/pyproject.toml index 3c19135..c405c6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ include = [ "/CHANGELOG.md", "/LICENSE", "/README", + "/docs", "/examples", "/pyproject.toml", "/src", diff --git a/src/livepeer_gateway/__init__.py b/src/livepeer_gateway/__init__.py index 9e4c284..da6d233 100644 --- a/src/livepeer_gateway/__init__.py +++ b/src/livepeer_gateway/__init__.py @@ -46,6 +46,7 @@ ) from .discovery import discover_orchestrators, discover_runners from .remote_signer import LivePaymentChallenge, LivePaymentSession +from .runner_payload import RunnerPayload, StreamPayload from .selection import ( RunnerSelectionCursor, runner_selector, @@ -79,6 +80,8 @@ "LivePaymentChallenge", "LivePaymentSession", "LiveRunnerProxy", + "RunnerPayload", + "StreamPayload", "LivepeerGatewayError", "LivepeerHTTPError", "NoRunnerAvailableError", diff --git a/src/livepeer_gateway/_http_continue.py b/src/livepeer_gateway/_http_continue.py new file mode 100644 index 0000000..d07fc31 --- /dev/null +++ b/src/livepeer_gateway/_http_continue.py @@ -0,0 +1,54 @@ +"""Bound aiohttp's native continue handshake without replacing response parsing.""" + +from __future__ import annotations + +import asyncio +import contextlib +from typing import Any + +import aiohttp + + +class _ContinueResponse(aiohttp.ClientResponse): + def __init__(self, *args: Any, continue_timeout: float, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._continue_timeout = continue_timeout + + async def start(self, connection: Any) -> aiohttp.ClientResponse: + continued = self._continue + if continued is None: + return await super().start(connection) + + # aiohttp releases this waiter on any informational response except 101. + # Only the wait is bounded; uploads/final headers can take longer once + # permission arrives. Final headers also end the wait through start(). + waiting = True + async with asyncio.timeout(self._continue_timeout) as handshake: + def allow_upload(future: asyncio.Future[bool]) -> None: + if waiting and not future.cancelled() and not handshake.expired(): + handshake.reschedule(None) + + continued.add_done_callback(allow_upload) + try: + return await super().start(connection) + finally: + waiting = False + continued.remove_done_callback(allow_upload) + + +async def _close_response(response: aiohttp.ClientResponse) -> None: + """Stop and join an attempt's writer before another attempt borrows its state.""" + writer = response._writer + response.close() + await _cancel_writer(writer) + + +async def _cancel_writer(writer: asyncio.Task[None] | None) -> None: + if writer is not None: + writer.cancel() + # aiohttp 3.9 tries write_eof() on cancellation. Its failure after the + # peer closes must not mask a final response that was already received. + with contextlib.suppress( + asyncio.CancelledError, ConnectionError, aiohttp.ClientError + ): + await writer diff --git a/src/livepeer_gateway/http.py b/src/livepeer_gateway/http.py index 964a32e..9bfec69 100644 --- a/src/livepeer_gateway/http.py +++ b/src/livepeer_gateway/http.py @@ -1,11 +1,14 @@ from __future__ import annotations import json +from functools import partial from typing import Any from urllib.parse import ParseResult, urlparse import aiohttp +from ._http_continue import _ContinueResponse, _close_response +from .runner_payload import RunnerPayload, _StreamState, _UploadBody from .errors import ( LivepeerHTTPError, LivepeerGatewayError, @@ -48,21 +51,32 @@ def _extract_error_message_from_body(body: str) -> str: return _truncate(body) -def _json_request_parts( +def _request_parts( url: str, *, method: str | None = None, - payload: dict[str, Any] | None = None, + payload: RunnerPayload | _StreamState = None, headers: dict[str, str] | None = None, -) -> tuple[str, dict[str, str], bytes | None]: +) -> tuple[str, dict[str, str], bytes | _UploadBody | None]: req_headers: dict[str, str] = { "Accept": "application/json", "User-Agent": "livepeer-python-gateway/0.1", } - body: bytes | None = None + body: bytes | _UploadBody | None = None if payload is not None: - req_headers["Content-Type"] = "application/json" + req_headers["Content-Type"] = "application/json" if isinstance(payload, dict) else "application/octet-stream" + if isinstance(payload, dict): body = json.dumps(payload).encode("utf-8") + elif isinstance(payload, _StreamState): + payload.require_replayable() + body = _UploadBody(payload) + elif payload is not None: + if isinstance(payload, str): + body = payload.encode("utf-8") + elif isinstance(payload, (bytes, bytearray, memoryview)): + body = bytes(payload) + else: + raise TypeError(f"Unsupported runner payload: {type(payload).__name__}") if headers: req_headers.update(headers) @@ -70,6 +84,30 @@ def _json_request_parts( return resolved_method, req_headers, body +def _request_session( + payload: RunnerPayload | _StreamState, + timeout: aiohttp.ClientTimeout, + handshake_timeout: float, +) -> tuple[aiohttp.ClientSession, dict[str, Any]]: + if not isinstance(payload, _StreamState): + return aiohttp.ClientSession(timeout=timeout), {} + session = aiohttp.ClientSession( + timeout=timeout, + response_class=partial(_ContinueResponse, continue_timeout=handshake_timeout), + ) + # aiohttp >= 3.10 may silently retry idempotent requests after disconnect. + # Stream resends are controlled by the SDK's replay state instead. + if hasattr(session, "_retry_connection"): + session._retry_connection = False + return session, {"expect100": payload.config.expect100, "allow_redirects": False} + + +def _request_failed(status: int, payload: RunnerPayload | _StreamState) -> bool: + return status >= 400 or ( + isinstance(payload, _StreamState) and status in (301, 302, 303, 307, 308) + ) + + def _raise_http_json_error( status: int, url: str, @@ -105,12 +143,12 @@ async def _request_body( url: str, *, method: str | None = None, - payload: dict[str, Any] | None = None, + payload: RunnerPayload | _StreamState = None, headers: dict[str, str] | None = None, timeout: float = 5.0, ) -> tuple[bytes, str]: """ - Make an async JSON-payload HTTP request and return the raw response body. + Make an HTTP request and return the raw response body. Returns ``(body, content_type)`` without assuming the response is JSON; request semantics and error mapping match request_json. @@ -119,23 +157,17 @@ async def _request_body( Raises LivepeerGatewayError on HTTP/network errors. """ - resolved_method, req_headers, body = _json_request_parts( - url, - method=method, - payload=payload, - headers=headers, - ) - try: - client_timeout = aiohttp.ClientTimeout(total=timeout) - async with aiohttp.ClientSession(timeout=client_timeout) as session: - async with session.request(resolved_method, url, data=body, headers=req_headers) as resp: - raw = await resp.read() + session, resp = await _open_request( + url, method=method, payload=payload, headers=headers, + timeout=aiohttp.ClientTimeout(total=timeout), handshake_timeout=timeout, + ) + async with session: + try: + raw = await resp.content.read() if isinstance(payload, _StreamState) else await resp.read() content_type = resp.content_type or "" - if resp.status >= 400: - _raise_http_json_error( - resp.status, url, raw.decode(errors="replace") - ) + finally: + await _close_response(resp) except (SignerRefreshRequired, SkipPaymentCycle, LivepeerGatewayError): raise except ConnectionRefusedError as e: @@ -159,6 +191,9 @@ async def _request_body( raise LivepeerGatewayError( f"HTTP JSON error: unexpected error: {e.__class__.__name__}: {e} (url={url})" ) from e + finally: + if isinstance(payload, _StreamState): + await payload.finish_attempt() return raw, content_type @@ -197,7 +232,7 @@ async def open_stream( url: str, *, method: str | None = None, - payload: dict[str, Any] | None = None, + payload: RunnerPayload | _StreamState = None, headers: dict[str, str] | None = None, connect_timeout: float = 10.0, ) -> tuple[aiohttp.ClientSession, aiohttp.ClientResponse]: @@ -206,31 +241,53 @@ async def open_stream( body, for streaming responses (SSE, chunked). The caller owns both and must close them. - No total timeout (streams run indefinitely) only connect/first-byte are bounded. + No total/read timeout; bounds connection and optional 100-continue handshake. Raises LivepeerHTTPError on >= 400 (e.g. the 402 payment retry). """ - resolved_method, req_headers, body = _json_request_parts( - url, - method=method, - payload=payload, - headers=headers, - ) - - timeout = aiohttp.ClientTimeout(total=None, sock_connect=connect_timeout, sock_read=None) - session = aiohttp.ClientSession(timeout=timeout) try: - resp = await session.request(resolved_method, url, data=body, headers=req_headers) + return await _open_request( + url, method=method, payload=payload, headers=headers, + timeout=aiohttp.ClientTimeout(total=None, sock_connect=connect_timeout, sock_read=None), + handshake_timeout=connect_timeout, + ) except (TimeoutError, aiohttp.ClientError) as e: - await session.close() raise LivepeerGatewayError( f"HTTP stream error: failed to reach endpoint: {getattr(e, 'message', e)} (url={url})" ) from e - if resp.status >= 400: - raw = await resp.text() - resp.release() - await session.close() - _raise_http_json_error(resp.status, url, raw) - return session, resp + + +async def _open_request( + url: str, + *, + method: str | None, + payload: RunnerPayload | _StreamState, + headers: dict[str, str] | None, + timeout: aiohttp.ClientTimeout, + handshake_timeout: float, +) -> tuple[aiohttp.ClientSession, aiohttp.ClientResponse]: + method, headers, body = _request_parts(url, method=method, payload=payload, headers=headers) + session, options = _request_session(payload, timeout, handshake_timeout) + if payload is None: + # aiohttp otherwise adds application/octet-stream even for an empty POST. + options["skip_auto_headers"] = {"Content-Type"} + resp = None + try: + resp = await session.request(method, url, data=body, headers=headers, **options) + if _request_failed(resp.status, payload): + # Read before cancelling the writer: aiohttp 3.13 closes the socket + # on cancellation, which can discard a fragmented payment challenge. + raw = (await resp.content.read()).decode(errors="replace") + _raise_http_json_error(resp.status, url, raw) + return session, resp + except BaseException: + try: + if resp is not None: + await _close_response(resp) + finally: + await session.close() + if isinstance(payload, _StreamState): + await payload.finish_attempt() + raise async def post_json( diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 8d80b3b..6bcb280 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -26,6 +26,7 @@ import aiohttp from aiohttp.helpers import parse_mimetype +from ._http_continue import _close_response from .channel_reader import ChannelReader from .errors import LivepeerGatewayError, LivepeerHTTPError, SignerRefreshRequired from .http import _post_empty, _request_body, open_stream, post_json, request_json @@ -36,6 +37,7 @@ _freeze_headers, get_signer_info, ) +from .runner_payload import RunnerPayload, _StreamState, _stream_state _LOG = logging.getLogger(__name__) @@ -188,6 +190,9 @@ class LiveRunnerCallStream: payment_session: LivePaymentSession | None _session: aiohttp.ClientSession = field(repr=False, compare=False) _response: aiohttp.ClientResponse = field(repr=False, compare=False) + _payload_state: _StreamState | None = field( + default=None, init=False, repr=False, compare=False + ) # True once the orchestrator reported the backing session gone. released: bool = False _payment_task: asyncio.Task[None] | None = field( @@ -219,10 +224,16 @@ def _start_payments( async def aclose(self) -> None: # Stop funding first: don't pay for a stream we are about to drop. - await _stop_funding(self._payment_task) - self._payment_task = None - self._response.release() - await self._session.close() + try: + await _stop_funding(self._payment_task) + finally: + self._payment_task = None + try: + await _close_response(self._response) + finally: + await self._session.close() + if self._payload_state is not None: + await self._payload_state.aclose() async def __aenter__(self) -> LiveRunnerCallStream: return self @@ -274,7 +285,6 @@ def __init__( runner_id: str = "", mode: str = "persistent", proxy: bool = False, - label: str = "", version: str = "", metadata: str = "", status: str = "ready", @@ -298,7 +308,6 @@ def __init__( self._mode = _normalize_runner_mode(mode) self._proxy = proxy self._price_info = price_info - self._label = label self._version = version self._metadata = metadata self._status = status @@ -445,8 +454,6 @@ def _payload(self) -> dict[str, Any]: payload["runner_id"] = self.runner_id if self._proxy: payload["proxy"] = True - if self._label: - payload["label"] = self._label if self._version: payload["version"] = self._version if self._metadata: @@ -609,7 +616,6 @@ async def register_runner( runner_id: str = "", mode: str = "persistent", proxy: bool = False, - label: str = "", version: str = "", metadata: str = "", status: str = "ready", @@ -634,7 +640,6 @@ async def register_runner( runner_id=runner_id, mode=mode, proxy=proxy, - label=label, version=version, metadata=metadata, status=status, @@ -755,7 +760,8 @@ async def call_runner( runner_url: str = ..., *, runner: LiveRunnerInstance | None = ..., - payload: dict[str, Any] | None = ..., + payload: RunnerPayload = ..., + content_type: str | None = ..., method: str = ..., signer_url: str | None = ..., signer_headers: dict[str, str] | None = ..., @@ -772,7 +778,8 @@ async def call_runner( runner_url: str = ..., *, runner: LiveRunnerInstance | None = ..., - payload: dict[str, Any] | None = ..., + payload: RunnerPayload = ..., + content_type: str | None = ..., method: str = ..., signer_url: str | None = ..., signer_headers: dict[str, str] | None = ..., @@ -788,7 +795,8 @@ async def call_runner( runner_url: str = "", *, runner: LiveRunnerInstance | None = None, - payload: dict[str, Any] | None = None, + payload: RunnerPayload | _StreamState = None, + content_type: str | None = None, method: str = "POST", signer_url: str | None = None, signer_headers: dict[str, str] | None = None, @@ -807,137 +815,153 @@ async def call_runner( (an image, ndjson) comes back unparsed in ``result.content`` + ``result.content_type``. The request asks for no particular format, so the app picks what it returns. + + Dictionaries serialize to JSON; strings encode as UTF-8; bytes and streams + send raw data. ``content_type`` overrides the default ``application/json`` + for dictionaries or ``application/octet-stream`` for other bodies. + Omitted/None payloads send an empty body. Bare files and async byte iterables + use StreamPayload defaults (100-continue and bounded replay buffering). """ - runner_url = runner_url.strip() or (runner.url.strip() if runner is not None else "") - if not runner_url: - raise LivepeerGatewayError("Live runner call requires runner_url") - request_payload = payload or {} - payment_type = _runner_payment_type(runner, payment_unit) if signer_url else "" - max_price: LiveRunnerPriceInfo | None = None - if signer_url and runner is not None and runner.price_info is not None: - max_price = _pad_runner_price(runner.price_info) - payer_address = "" - if signer_url: - signer = await get_signer_info(signer_url, _freeze_headers(signer_headers)) - payer_address = cast(str, signer.address) - challenge: LivePaymentChallenge | None = None - attempts = (max(0, int(max_payment_challenge_retries)) + 1) * 2 - for attempt in range(attempts): - payment_session: LivePaymentSession | None = None - session_id = "" - needs_ongoing_funding = False - # No preferred format: the app, or the upstream it fronts, picks. Only - # control-plane calls ask for JSON. - request_headers: dict[str, str] = {"Accept": "*/*"} + state = _stream_state(payload) + owned = state is not None and not isinstance(payload, _StreamState) + payload = state if state is not None else payload + try: + runner_url = runner_url.strip() or (runner.url.strip() if runner is not None else "") + if not runner_url: + raise LivepeerGatewayError("Live runner call requires runner_url") + payment_type = _runner_payment_type(runner, payment_unit) if signer_url else "" + max_price: LiveRunnerPriceInfo | None = None + if signer_url and runner is not None and runner.price_info is not None: + max_price = _pad_runner_price(runner.price_info) + payer_address = "" if signer_url: - request_headers[_LIVE_RUNNER_PAYER_ADDRESS_HEADER] = payer_address - # Pending challenge means payment is needed. - if challenge is not None: - try: - payment_session, payment = await _get_runner_payment( - challenge, - payment_type=payment_type, - app=runner.app if runner is not None else None, - signer_url=signer_url or "", - signer_headers=signer_headers, - max_price=max_price, - ) - except SignerRefreshRequired as e: - if attempt + 1 >= attempts: - raise - # Could happen if embedded payment params expire; just retry in this case. - _LOG.info( - "Live runner reservation payment challenge needs refresh; retrying with a fresh challenge: %s", - e, - ) - challenge = None - continue - request_headers["Livepeer-Payment"] = payment.payment - request_headers["Livepeer-Segment"] = payment.seg_creds or "" - session_id = challenge.manifest_id + signer = await get_signer_info(signer_url, _freeze_headers(signer_headers)) + payer_address = cast(str, signer.address) + challenge: LivePaymentChallenge | None = None + attempts = (max(0, int(max_payment_challenge_retries)) + 1) * 2 + for attempt in range(attempts): + payment_session: LivePaymentSession | None = None + session_id = "" + needs_ongoing_funding = False + # No preferred format: the app, or the upstream it fronts, picks. Only + # control-plane calls ask for JSON. + request_headers: dict[str, str] = {"Accept": "*/*"} + if content_type is not None: + request_headers["Content-Type"] = content_type + if signer_url: + request_headers[_LIVE_RUNNER_PAYER_ADDRESS_HEADER] = payer_address + # Pending challenge means payment is needed. + if challenge is not None: + try: + payment_session, payment = await _get_runner_payment( + challenge, + payment_type=payment_type, + app=runner.app if runner is not None else None, + signer_url=signer_url or "", + signer_headers=signer_headers, + max_price=max_price, + ) + except SignerRefreshRequired as e: + if attempt + 1 >= attempts: + raise + # Could happen if embedded payment params expire; just retry in this case. + _LOG.info( + "Live runner reservation payment challenge needs refresh; retrying with a fresh challenge: %s", + e, + ) + challenge = None + continue + request_headers["Livepeer-Payment"] = payment.payment + request_headers["Livepeer-Segment"] = payment.seg_creds or "" + session_id = challenge.manifest_id - # Metered pricing bills for as long as the work runs. - needs_ongoing_funding = payment_type == "live" + # Metered pricing bills for as long as the work runs. + needs_ongoing_funding = payment_type == "live" - try: - request_kwargs: dict[str, Any] = {"timeout": timeout} - if request_headers: - request_kwargs["headers"] = request_headers - - if stream: - # Hand back the live response unbuffered. open_stream raises on a 402 - # before any body, so the payment retry below still catches it. - session, resp = await open_stream( - runner_url, - method=method, - payload=request_payload, - headers=request_headers or None, - ) - call_stream = LiveRunnerCallStream( - resp.status, - resp.headers, - runner_url, - runner, - None if payment_type == "fixed" else payment_session, - session, - resp, - ) - # The stream outlives this call, so it owns the funding. - if needs_ongoing_funding: - call_stream._start_payments(cast(LivePaymentSession, payment_session)) - return call_stream - - # The request ends with this call, so the funding ends with it. - pay_task = ( - _start_funding(cast(LivePaymentSession, payment_session)) - if needs_ongoing_funding - else None - ) try: - body, content_type = await _request_body( - runner_url, - method=method, - payload=request_payload, - **request_kwargs, + if stream: + # Payment challenges are handled before returning a live response. + session, resp = await open_stream( + runner_url, + method=method, + payload=payload, + headers=request_headers or None, + connect_timeout=timeout, + ) + call_stream = LiveRunnerCallStream( + resp.status, + resp.headers, + runner_url, + runner, + None if payment_type == "fixed" else payment_session, + session, + resp, + ) + # The stream outlives this call, so it owns the funding. + if needs_ongoing_funding: + call_stream._start_payments(cast(LivePaymentSession, payment_session)) + if owned: + call_stream._payload_state = state + owned = False + return call_stream + + # The request ends with this call, so the funding ends with it. + pay_task = ( + _start_funding(cast(LivePaymentSession, payment_session)) + if needs_ongoing_funding + else None ) - finally: - await _stop_funding(pay_task) - # Non-JSON bodies (an image, ndjson) are handed back unparsed in `content`. - is_json = _is_json_content_type(content_type) - data: dict[str, Any] = {} - if is_json: try: - data = json.loads(body) - except (UnicodeDecodeError, json.JSONDecodeError) as e: - raise LivepeerGatewayError( - f"HTTP JSON error: endpoint did not return valid JSON: {e} " - f"(url={runner_url}, content_type={content_type})" - ) from e - if not isinstance(data, dict): - raise LivepeerGatewayError( - f"Live runner call expected JSON object, got {type(data).__name__}" + body, response_content_type = await _request_body( + runner_url, + method=method, + payload=payload, + headers=request_headers, + timeout=timeout, ) - return LiveRunnerCallResult( - data, - runner_url=runner_url, - runner=runner, - session_id=( - session_id - or (data["session_id"].strip() if isinstance(data.get("session_id"), str) else "") - ), - payment_session=None if payment_type == "fixed" else payment_session, - content=None if is_json else body, - content_type=content_type, - ) - except LivepeerHTTPError as e: - if e.status_code != 402: - raise - if not signer_url: - raise LivepeerGatewayError("Live runner paid call requires signer_url") from e - challenge = _parse_runner_payment_challenge(e) - continue + finally: + await _stop_funding(pay_task) + # Non-JSON bodies (an image, ndjson) are handed back unparsed in `content`. + is_json = _is_json_content_type(response_content_type) + data: dict[str, Any] = {} + if is_json: + try: + data = json.loads(body) + except (UnicodeDecodeError, json.JSONDecodeError) as e: + raise LivepeerGatewayError( + f"HTTP JSON error: endpoint did not return valid JSON: {e} " + f"(url={runner_url}, content_type={response_content_type})" + ) from e + if not isinstance(data, dict): + raise LivepeerGatewayError( + f"Live runner call expected JSON object, got {type(data).__name__}" + ) + return LiveRunnerCallResult( + data, + runner_url=runner_url, + runner=runner, + session_id=( + session_id + or (data["session_id"].strip() if isinstance(data.get("session_id"), str) else "") + ), + payment_session=None if payment_type == "fixed" else payment_session, + content=None if is_json else body, + content_type=response_content_type, + ) + except LivepeerHTTPError as e: + if e.status_code != 402: + raise + if not signer_url: + raise LivepeerGatewayError("Live runner paid call requires signer_url") from e + if state is not None: + state.require_replayable(e) + challenge = _parse_runner_payment_challenge(e) + continue - raise LivepeerGatewayError("Live runner call exhausted payment challenge retries") + raise LivepeerGatewayError("Live runner call exhausted payment challenge retries") + finally: + if owned: + await state.aclose() def _parse_runner_payment_challenge(error: LivepeerHTTPError) -> LivePaymentChallenge: diff --git a/src/livepeer_gateway/runner_payload.py b/src/livepeer_gateway/runner_payload.py new file mode 100644 index 0000000..f127cd4 --- /dev/null +++ b/src/livepeer_gateway/runner_payload.py @@ -0,0 +1,272 @@ +"""Runner upload configuration and operation-scoped replay state.""" + +from __future__ import annotations + +import asyncio +import contextlib +import inspect +import io +import logging +import tempfile +from collections.abc import AsyncIterable, AsyncIterator, Callable +from dataclasses import dataclass +from typing import Any, BinaryIO + +from aiohttp.payload import Payload + +from ._http_continue import _cancel_writer +from .errors import LivepeerGatewayError + +_LOG = logging.getLogger(__name__) +_CHUNK_SIZE = 64 * 1024 + + +@dataclass(frozen=True, kw_only=True) +class StreamPayload: + """A caller-owned source with 100-continue and bounded replay buffering. + + Limits are decimal bytes. Overflow or storage failure disables later retries + without interrupting the upload. Seekable files rewind instead of buffering, + even with ``retry_buffer=False``. Bare files and iterables use these defaults. + """ + + source: AsyncIterable[bytes] | BinaryIO + expect100: bool = True + retry_buffer: bool = True + retry_buffer_memory_limit: int = 25_000_000 + retry_buffer_limit: int = 250_000_000 + + def __post_init__(self) -> None: + if not (0 < self.retry_buffer_memory_limit <= self.retry_buffer_limit): + raise ValueError("Replay buffer limits must satisfy 0 < memory limit <= total limit") + read = getattr(self.source, "read", None) + if isinstance(self.source, io.TextIOBase) or ( + not isinstance(self.source, AsyncIterable) + and (not callable(read) or inspect.iscoroutinefunction(read)) + ): + raise TypeError("StreamPayload requires a synchronous binary file or async byte iterable") + + +type RunnerPayload = ( + StreamPayload | AsyncIterable[bytes] | BinaryIO | dict[str, Any] + | bytes | bytearray | memoryview | str | None +) + + +class _ReplayUnavailableError(LivepeerGatewayError): + pass + + +async def _file_io[T](operation: Callable[[], T], *, threaded: bool = True) -> T: + """Join worker I/O before cancellation can hand its file to another attempt.""" + if not threaded: + return operation() + task = asyncio.create_task(asyncio.to_thread(operation)) + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + with contextlib.suppress(Exception): + await task + raise + + +class _StreamState: + def __init__(self, config: StreamPayload) -> None: + self.config = config + self.source = config.source + self.started = self.closed = self.eof = self.failed = False + self.cache_size = 0 + self.cache: tempfile.SpooledTemporaryFile[bytes] | None = None + self._iterator: AsyncIterator[bytes] | None = None + self._pending: asyncio.Task[bytes] | None = None + self._writer: asyncio.Task[None] | None = None + self._start_position: int | None = None + if not isinstance(self.source, AsyncIterable): + with contextlib.suppress(AttributeError, OSError, ValueError): + if self.source.seekable(): + self._start_position = self.source.tell() + self._buffering = config.retry_buffer and self._start_position is None + + @property + def replayable(self) -> bool: + if self.closed: + return False + source_open = not ( + getattr(self.source, "closed", False) + or (hasattr(self.source, "ag_frame") and self.source.ag_frame is None) + ) + if self._start_position is not None: + return source_open + return not self.failed and ( + (self.eof and self._buffering) + or (source_open and (not self.started or self._buffering)) + ) + + def require_replayable(self, cause: Exception | None = None) -> None: + if not self.replayable: + raise _ReplayUnavailableError( + "Cannot retry runner call: upload source is closed or its consumed data is no longer replayable" + ) from cause + + async def _drop_cache(self) -> None: + cache, self.cache = self.cache, None + self.cache_size = 0 + if cache is not None: + with contextlib.suppress(OSError): + await _file_io(cache.close, threaded=cache._rolled) + + async def _retain(self, chunk: bytes) -> None: + size = self.cache_size + len(chunk) + retained = False + storage_error = None + try: + if size > self.config.retry_buffer_limit: + self._buffering = False + await self._drop_cache() + return + if self.cache is None: + self.cache = tempfile.SpooledTemporaryFile( + max_size=self.config.retry_buffer_memory_limit, mode="w+b" + ) + cache = self.cache + on_disk = size > self.config.retry_buffer_memory_limit + + def append() -> None: + nonlocal retained, storage_error + try: + if on_disk: + cache.rollover() # Spill before a large chunk grows the RAM buffer. + cache.seek(self.cache_size) + cache.write(chunk) + retained = True + except OSError as exc: + storage_error = exc + raise + + await _file_io(append, threaded=on_disk) + except OSError as exc: + storage_error = exc + finally: + # _file_io joins a cancelled write. Commit it exactly once even if + # cancellation prevents chunks() from receiving the result. + if retained: + self.cache_size = size + self._pending = None + elif storage_error is not None: + self._buffering = False + await self._drop_cache() + _LOG.warning("Upload replay storage failed; continuing without replay", exc_info=storage_error) + + async def _read_source(self) -> bytes: + try: + if isinstance(self.source, AsyncIterable): + if self._iterator is None: + self._iterator = aiter(self.source) + try: + chunk = await anext(self._iterator) + except StopAsyncIteration: + self.eof = True + return b"" + else: + chunk = await _file_io(lambda: self.source.read(_CHUNK_SIZE)) + if not chunk: + self.eof = True + if not isinstance(chunk, bytes): + raise TypeError("Upload streams must produce bytes") + return chunk + except BaseException: + self.failed = True + raise + + async def chunks(self) -> AsyncIterator[bytes]: + self.require_replayable() + if self._start_position is not None and self.started: + if self._pending is not None: + with contextlib.suppress(Exception): + await asyncio.shield(self._pending) + self._pending = None + try: + await _file_io(lambda: self.source.seek(self._start_position)) + except (OSError, ValueError) as exc: + self._start_position = None + self.failed = True + raise _ReplayUnavailableError("Cannot rewind upload source for retry") from exc + self.eof = self.failed = False + + # Only this attempt touches the cache. The pending read cannot append or + # discard it while we replay the prefix, so no lock or offsets are needed. + position = 0 + while position < self.cache_size: + cache = self.cache + + def read_cached() -> bytes: + cache.seek(position) + return cache.read(min(_CHUNK_SIZE, self.cache_size - position)) + + try: + chunk = await _file_io(read_cached, threaded=cache._rolled) + except OSError as exc: + self._buffering = False + await self._drop_cache() + raise _ReplayUnavailableError("Cannot read upload replay buffer") from exc + position += len(chunk) + yield chunk + + while self._pending is not None or not self.eof: + if self._pending is None: + self.started = True + self._pending = asyncio.create_task(self._read_source()) + # A cancelled attempt leaves the result available to its successor. + chunk = await asyncio.shield(self._pending) + if chunk and self._buffering: + await self._retain(chunk) + self._pending = None + if chunk: + yield chunk + + async def aclose(self) -> None: + if self.closed: + return + self.closed = True + await self.finish_attempt() + if self._pending is not None: + self._pending.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await self._pending + self._pending = None + await self._drop_cache() + + async def finish_attempt(self) -> None: + """Join the HTTP writer without cancelling the shared source read.""" + writer, self._writer = self._writer, None + await _cancel_writer(writer) + + +def _stream_state(payload: RunnerPayload | _StreamState) -> _StreamState | None: + if isinstance(payload, _StreamState): + return payload + if isinstance(payload, StreamPayload): + return _StreamState(payload) + if isinstance(payload, AsyncIterable) or callable(getattr(payload, "read", None)): + return _StreamState(StreamPayload(source=payload)) + if payload is not None and not isinstance(payload, (dict, bytes, bytearray, memoryview, str)): + raise TypeError(f"Unsupported runner payload: {type(payload).__name__}") + return None + + +class _UploadBody(Payload): + """An attempt borrows the source and cache; aiohttp must close neither.""" + + _autoclose = True + + def __init__(self, state: _StreamState) -> None: + super().__init__(state, content_type="application/octet-stream") + self.state = state + + def decode(self, encoding: str = "utf-8", errors: str = "strict") -> str: + raise TypeError("Streaming request bodies cannot be decoded") + + async def write(self, writer: Any) -> None: + self.state._writer = asyncio.current_task() + async for chunk in self.state.chunks(): + await writer.write(chunk) diff --git a/src/livepeer_gateway/selection.py b/src/livepeer_gateway/selection.py index 588c8ec..0118198 100644 --- a/src/livepeer_gateway/selection.py +++ b/src/livepeer_gateway/selection.py @@ -21,6 +21,7 @@ _live_runner_price_info_from_json, call_runner, ) +from .runner_payload import RunnerPayload, _ReplayUnavailableError, _StreamState, _stream_state _LOG = logging.getLogger(__name__) @@ -38,14 +39,19 @@ def __init__( self, candidates: Sequence[LiveRunnerInstance], *, - body: dict[str, Any] | None = None, + body: RunnerPayload = None, + content_type: str | None = None, method: str = "POST", signer_url: str | None = None, signer_headers: dict[str, str] | None = None, timeout: float = 5.0, ) -> None: self._candidates = list(candidates) - self._body = dict(body or {}) + self._body = dict(body) if isinstance(body, dict) else body + self._content_type = content_type + self._payload_state: _StreamState | None = None + self._closed = False + self._next_running = False self._method = method self._signer_url = signer_url self._signer_headers = signer_headers @@ -58,47 +64,84 @@ def candidates(self) -> tuple[LiveRunnerInstance, ...]: return tuple(self._candidates) async def next(self) -> LiveRunnerCallResult: - while self._next_index < len(self._candidates): - runner = self._candidates[self._next_index] - self._next_index += 1 + if self._closed: + raise LivepeerGatewayError("Runner selection cursor is closed") + if self._next_running: + raise LivepeerGatewayError("Runner selection cursor.next() is already running") + self._next_running = True + try: + if self._payload_state is None: + self._payload_state = _stream_state(self._body) + while self._next_index < len(self._candidates): + if self._payload_state is not None: + self._payload_state.require_replayable() + runner = self._candidates[self._next_index] + self._next_index += 1 + try: + kwargs: dict[str, Any] = { + "runner": runner, + "payload": self._payload_state if self._payload_state is not None else self._body, + "method": self._method, + "timeout": self._timeout, + } + if self._content_type is not None: + kwargs["content_type"] = self._content_type + if self._signer_url is not None: + kwargs["signer_url"] = self._signer_url + if self._signer_headers is not None: + kwargs["signer_headers"] = self._signer_headers + result = await call_runner(**kwargs) + except _ReplayUnavailableError: + raise + except Exception as e: + reason = str(e) + _LOG.debug( + "select_runner candidate failed: %s (%s)", + runner.url, + reason, + ) + self.rejections.append(RunnerRejection(url=runner.url, reason=reason)) + if self._payload_state is not None: + self._payload_state.require_replayable(e) + continue + + _LOG.debug("select_runner selected: %s", runner.url) + return result + + _LOG.debug( + "select_runner failed: all %d runners rejected", + len(self._candidates), + ) + raise NoRunnerAvailableError( + f"All runners failed ({len(self.rejections)} tried)", + rejections=list(self.rejections), + ) + except BaseException: try: - kwargs: dict[str, Any] = { - "runner": runner, - "payload": self._body, - "method": self._method, - "timeout": self._timeout, - } - if self._signer_url is not None: - kwargs["signer_url"] = self._signer_url - if self._signer_headers is not None: - kwargs["signer_headers"] = self._signer_headers - result = await call_runner(**kwargs) - except Exception as e: - reason = str(e) - _LOG.debug( - "select_runner candidate failed: %s (%s)", - runner.url, - reason, - ) - self.rejections.append(RunnerRejection(url=runner.url, reason=reason)) - continue + await self.aclose() + except Exception: + _LOG.warning("Runner cursor cleanup failed", exc_info=True) + raise + finally: + self._next_running = False - _LOG.debug("select_runner selected: %s", runner.url) - return result + async def aclose(self) -> None: + """Release the replay cache without closing the caller's upload source.""" + self._closed = True + if self._payload_state is not None: + await self._payload_state.aclose() - _LOG.debug( - "select_runner failed: all %d runners rejected", - len(self._candidates), - ) - raise NoRunnerAvailableError( - f"All runners failed ({len(self.rejections)} tried)", - rejections=list(self.rejections), - ) + async def __aenter__(self) -> RunnerSelectionCursor: + return self + + async def __aexit__(self, *exc: object) -> None: + await self.aclose() async def runner_selector( *, - body: dict[str, Any] | None = None, + body: RunnerPayload = None, + content_type: str | None = None, method: str = "POST", orchestrators: Sequence[str] | str | None = None, signer_url: str | None = None, @@ -134,6 +177,7 @@ async def runner_selector( return RunnerSelectionCursor( candidates, body=body, + content_type=content_type, method=method, signer_url=signer_url, signer_headers=signer_headers, diff --git a/tests/test_call_runner_uploads.py b/tests/test_call_runner_uploads.py new file mode 100644 index 0000000..b8e9364 --- /dev/null +++ b/tests/test_call_runner_uploads.py @@ -0,0 +1,639 @@ +from __future__ import annotations + +import asyncio +import io +import json +from contextlib import ExitStack, asynccontextmanager +from types import SimpleNamespace +from unittest import mock + +import pytest + +from livepeer_gateway import ( + LiveRunnerInstance, + RunnerSelectionCursor, + StreamPayload, + call_runner, +) +from livepeer_gateway import live_runner +from livepeer_gateway.errors import ( + LivepeerGatewayError, + LivepeerHTTPError, + NoRunnerAvailableError, +) + + +CHALLENGE = { + "payment_params": "opaque", + "manifest_id": "session", + "payment_url": "http://payment.example/session", +} + + +@pytest.fixture +def signer(monkeypatch): + monkeypatch.setattr(live_runner, "get_signer_info", mock.AsyncMock(return_value=SimpleNamespace(address="payer"))) + payment = mock.AsyncMock(return_value=(None, SimpleNamespace(payment="paid", seg_creds="segment"))) + monkeypatch.setattr(live_runner, "_get_runner_payment", payment) + return payment + + +@pytest.fixture +async def no_loop_errors(): + """A failed asyncio callback is logged, rather than failing the awaiting task.""" + loop = asyncio.get_running_loop() + previous = loop.get_exception_handler() + errors = [] + loop.set_exception_handler(lambda _, context: errors.append(context)) + try: + yield + # Future cancellation can queue callbacks after request cleanup returns. + await asyncio.sleep(0) + await asyncio.sleep(0) + finally: + loop.set_exception_handler(previous) + assert not errors, errors + + +@asynccontextmanager +async def serve(handler): + tasks = set() + errors = [] + + async def connected(reader, writer): + task = asyncio.current_task() + tasks.add(task) + try: + await handler(reader, writer) + except (ConnectionError, asyncio.IncompleteReadError): + pass + except Exception as exc: + errors.append(exc) + finally: + writer.close() + try: + await writer.wait_closed() + except ConnectionError: + pass + tasks.discard(task) + + server = await asyncio.start_server(connected, "127.0.0.1", 0) + async with server: + port = server.sockets[0].getsockname()[1] + try: + yield f"http://127.0.0.1:{port}" + finally: + if tasks: + await asyncio.wait_for(asyncio.gather(*tasks), 2) + if errors: + raise errors[0] + + +async def headers(reader): + raw = await reader.readuntil(b"\r\n\r\n") + lines = raw.decode().split("\r\n") + return dict(line.split(": ", 1) for line in lines[1:] if line) + + +async def body(reader, request_headers): + if request_headers.get("Transfer-Encoding") == "chunked": + chunks = [] + while True: + chunk = await read_chunk(reader) + if not chunk: + return b"".join(chunks) + chunks.append(chunk) + return await reader.readexactly(int(request_headers.get("Content-Length", 0))) + + +async def read_chunk(reader): + line = await reader.readline() + if not line: + raise asyncio.IncompleteReadError(b"", 1) + size = int(line, 16) + chunk = await reader.readexactly(size) + assert await reader.readexactly(2) == b"\r\n" + return chunk + + +async def respond(writer, status=200, data=None, *, fragment=False, location=None): + data = {"ok": True} if data is None else data + raw = json.dumps(data).encode() + redirect = f"Location: {location}\r\n" if location else "" + head = ( + f"HTTP/1.1 {status} Result\r\nContent-Type: application/json\r\n" + f"Content-Length: {len(raw)}\r\n{redirect}Connection: close\r\n\r\n".encode() + ) + writer.write(head if fragment else head + raw) + await writer.drain() + if fragment: + await asyncio.sleep(0.02) + writer.write(raw) + await writer.drain() + + +async def allow_upload(writer): + writer.write(b"HTTP/1.1 100 Continue\r\n\r\n") + await writer.drain() + + +class Source: + def __init__(self, *chunks): + self.chunks = iter(chunks) + self.iterations = 0 + self.reads = 0 + self.closed = False + + def __aiter__(self): + self.iterations += 1 + return self + + async def __anext__(self): + self.reads += 1 + try: + return next(self.chunks) + except StopIteration: + raise StopAsyncIteration from None + + async def aclose(self): + self.closed = True + + +def candidate(url): + return LiveRunnerInstance(url, "test", "runner", "single-shot", url, {}) + + +CUSTOM_TYPE = "application/vnd.test+json; charset=utf-8" + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("kwargs, expected, content_type", [ + ({}, b"", None), + ({"payload": None}, b"", None), + ({"payload": {}}, b"{}", "application/json"), + ({"payload": {"x": 1}}, b'{"x": 1}', "application/json"), + ({"payload": "héllo"}, "héllo".encode(), "application/octet-stream"), + ({"payload": b"\x00\xff"}, b"\x00\xff", "application/octet-stream"), + ({"payload": bytearray(b"abc")}, b"abc", "application/octet-stream"), + ({"payload": memoryview(b"abc")}, b"abc", "application/octet-stream"), + ({"payload": b""}, b"", "application/octet-stream"), + ({"payload": ""}, b"", "application/octet-stream"), + ({"payload": {"x": 1}, "content_type": CUSTOM_TYPE}, b'{"x": 1}', CUSTOM_TYPE), + ({"payload": b"abc", "content_type": CUSTOM_TYPE}, b"abc", CUSTOM_TYPE), + ({"payload": "abc", "content_type": CUSTOM_TYPE}, b"abc", CUSTOM_TYPE), + ({"payload": None, "content_type": CUSTOM_TYPE}, b"", CUSTOM_TYPE), +]) +async def test_request_bodies(kwargs, expected, content_type, stream): + async def handler(reader, writer): + request_headers = await headers(reader) + assert "Expect" not in request_headers + assert request_headers["Accept"] == "*/*" + assert request_headers.get("Content-Type") == content_type + assert await body(reader, request_headers) == expected + await respond(writer) + + async with serve(handler) as url: + result = await call_runner(url, stream=stream, **kwargs) + if stream: + async with result: + assert json.loads(b"".join([chunk async for chunk in result.aiter_bytes()])) == {"ok": True} + else: + assert result.data == {"ok": True} + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_early_payment_challenge_does_not_touch_source( + stream, signer, no_loop_errors +): + source = Source(b"one", b"two") + attempts = 0 + + async def handler(reader, writer): + nonlocal attempts + request_headers = await headers(reader) + attempts += 1 + assert request_headers["Expect"] == "100-continue" + if attempts == 1: + assert source.iterations == source.reads == 0 + await respond(writer, 402, CHALLENGE, fragment=True) + assert await reader.read() == b"" + return + assert request_headers["Livepeer-Payment"] == "paid" + assert source.iterations == source.reads == 0 + await allow_upload(writer) + assert await body(reader, request_headers) == b"onetwo" + await respond(writer) + + async with serve(handler) as url: + # Cancelling the upload writer as soon as final headers arrive can + # discard a fragmented 402 body on newer aiohttp. Bound the whole test + # so a regression fails rather than waiting forever for payment JSON. + result = await asyncio.wait_for( + call_runner( + url, + payload=StreamPayload(source=source, retry_buffer=False), + signer_url="http://signer.example", + payment_unit="fixed", + stream=stream, + ), + 2, + ) + if stream: + await result.aclose() + assert attempts == 2 + assert source.reads == 3 + assert not source.closed + signer.assert_awaited_once() + challenge = signer.call_args.args[0] + assert challenge.payment_params == CHALLENGE["payment_params"] + assert challenge.manifest_id == CHALLENGE["manifest_id"] + assert challenge.payment_url == CHALLENGE["payment_url"] + + +@pytest.mark.parametrize("status,kind", [(402, "nonseekable"), (503, "iterator"), (307, "file")]) +async def test_early_rejection_preserves_source_for_next_candidate(tmp_path, status, kind): + class NonSeekable(io.BytesIO): + def seekable(self): + return False + + with ExitStack() as sources: + if kind == "file": + path = tmp_path / "upload.bin" + path.write_bytes(b"skip-body") + source = sources.enter_context(path.open("rb")) + source.seek(5) + elif kind == "nonseekable": + source = sources.enter_context(NonSeekable(b"body")) + else: + source = Source(b"bo", b"dy") + + def assert_unread(): + assert not source.closed + if isinstance(source, Source): + assert source.iterations == source.reads == 0 + else: + assert source.tell() == (5 if kind == "file" else 0) + + async def reject(reader, writer): + await headers(reader) + assert_unread() + await respond(writer, status, location=second if status == 307 else None) + assert await reader.read() == b"" + + async def accept(reader, writer): + request_headers = await headers(reader) + assert_unread() + await allow_upload(writer) + assert await body(reader, request_headers) == b"body" + await respond(writer) + + async with serve(reject) as first, serve(accept) as second: + payload = source if kind == "file" else StreamPayload(source=source, retry_buffer=False) + async with RunnerSelectionCursor([candidate(first), candidate(second)], body=payload) as cursor: + result = await cursor.next() + assert result.data == {"ok": True} and result.runner_url == second + assert len(cursor.rejections) == 1 + assert not source.closed + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_expect_timeout_aborts_without_consumption(stream, no_loop_errors): + source = Source(b"never") + + async def handler(reader, writer): + await headers(reader) + assert await reader.read() == b"" + + async with serve(handler) as url: + with pytest.raises(LivepeerGatewayError): + await asyncio.wait_for( + call_runner(url, payload=source, stream=stream, timeout=0.05), 1 + ) + assert source.iterations == source.reads == 0 + assert not source.closed + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_expect_can_be_disabled(stream): + source = Source(b"direct") + + async def handler(reader, writer): + request_headers = await headers(reader) + assert "Expect" not in request_headers + assert await body(reader, request_headers) == b"direct" + await respond(writer) + + async with serve(handler) as url: + result = await call_runner( + url, payload=StreamPayload(source=source, expect100=False), stream=stream + ) + if stream: + await result.aclose() + assert not source.closed + + +async def test_buffered_upload_can_be_reused_by_cursor_until_close(): + source = Source(b"one", b"two") + received = [] + + async def handler(reader, writer): + request_headers = await headers(reader) + assert request_headers["Content-Type"] == "video/test" + await allow_upload(writer) + received.append(await body(reader, request_headers)) + await respond(writer) + + async with serve(handler) as url: + async with RunnerSelectionCursor( + [candidate(url), candidate(url)], body=source, content_type="video/test" + ) as cursor: + await cursor.next() + state = cursor._payload_state + cache = state.cache + assert cache is not None and not cache.closed + await cursor.next() + assert cache.closed and state.closed + with pytest.raises(LivepeerGatewayError, match="closed"): + await cursor.next() + assert received == [b"onetwo", b"onetwo"] + assert source.reads == 3 and not source.closed + + +async def test_seekable_file_replays_from_initial_position_without_buffering(): + source = io.BytesIO(b"prefix-data") + source.seek(7) + received = [] + + async def handler(reader, writer): + request_headers = await headers(reader) + await allow_upload(writer) + received.append(await body(reader, request_headers)) + await respond(writer, 503 if len(received) == 1 else 200) + + async with serve(handler) as url: + async with RunnerSelectionCursor( + [candidate(url), candidate(url)], + body=StreamPayload(source=source, retry_buffer=False), + ) as cursor: + await cursor.next() + assert cursor._payload_state.cache is None + assert received == [b"data", b"data"] + assert not source.closed + + +@pytest.mark.parametrize("options", [ + {"retry_buffer": False}, + {"retry_buffer_memory_limit": 3, "retry_buffer_limit": 6}, +]) +async def test_unreplayable_upload_finishes_but_stops_failover(options): + source = Source(b"prefix", b"over-limit", b"tail") + attempts = 0 + + async def handler(reader, writer): + nonlocal attempts + attempts += 1 + request_headers = await headers(reader) + await allow_upload(writer) + assert await body(reader, request_headers) == b"prefixover-limittail" + await respond(writer, 503) + + async with serve(handler) as url: + cursor = RunnerSelectionCursor( + [candidate(url), candidate(url)], body=StreamPayload(source=source, **options) + ) + with pytest.raises(LivepeerGatewayError, match="no longer replayable") as error: + await cursor.next() + assert isinstance(error.value.__cause__, LivepeerHTTPError) + assert cursor._payload_state.closed and cursor._payload_state.cache is None + assert attempts == 1 and not source.closed + + +@pytest.mark.parametrize("success", [False, True]) +async def test_exhausted_cursor_releases_cache(success): + source = Source(b"data") + + async def handler(reader, writer): + request_headers = await headers(reader) + await allow_upload(writer) + await body(reader, request_headers) + await respond(writer, 200 if success else 503) + + async with serve(handler) as url: + cursor = RunnerSelectionCursor([candidate(url)], body=StreamPayload(source=source, retry_buffer=not success)) + if success: + await cursor.next() + with pytest.raises(NoRunnerAvailableError): + await cursor.next() + assert cursor._payload_state.closed + assert cursor._payload_state.cache is None + assert not source.closed + + +async def test_disconnected_upload_failover_adopts_pending_source_read(): + read_started = asyncio.Event() + finish_read = asyncio.Event() + source_cancelled = False + source_reads = [] + rejections = 0 + + async def source(): + nonlocal source_cancelled + try: + source_reads.append(b"prefix") + yield b"prefix" + read_started.set() + await finish_read.wait() + source_reads.append(b"middle") + yield b"middle" + source_reads.append(b"tail") + yield b"tail" + except asyncio.CancelledError: + source_cancelled = True + raise + + async def reject(reader, writer): + nonlocal rejections + rejections += 1 + await headers(reader) + await allow_upload(writer) + assert await read_chunk(reader) == b"prefix" + await read_started.wait() + + async def accept(reader, writer): + request_headers = await headers(reader) + assert not source_cancelled + await allow_upload(writer) + assert await read_chunk(reader) == b"prefix" + finish_read.set() + assert await body(reader, request_headers) == b"middletail" + await respond(writer) + + async with serve(reject) as first, serve(accept) as second: + async with RunnerSelectionCursor( + [candidate(first), candidate(second)], body=source(), method="PUT" + ) as cursor: + assert (await asyncio.wait_for(cursor.next(), 2)).data == {"ok": True} + assert not source_cancelled + assert source_reads == [b"prefix", b"middle", b"tail"] + assert rejections == 1 + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_partial_upload_payment_retry_replays_prefix(stream, signer): + read_started = asyncio.Event() + finish_read = asyncio.Event() + attempts = 0 + + async def source(): + yield b"prefix" + read_started.set() + await finish_read.wait() + yield b"tail" + + async def handler(reader, writer): + nonlocal attempts + request_headers = await headers(reader) + attempts += 1 + await allow_upload(writer) + assert await read_chunk(reader) == b"prefix" + if attempts == 1: + await read_started.wait() + await respond(writer, 402, CHALLENGE, fragment=True) + else: + assert request_headers["Livepeer-Payment"] == "paid" + finish_read.set() + assert await body(reader, request_headers) == b"tail" + await respond(writer) + + async with serve(handler) as url: + result = await asyncio.wait_for( + call_runner( + url, + payload=source(), + signer_url="http://signer.example", + payment_unit="fixed", + stream=stream, + ), + 2, + ) + if stream: + await result.aclose() + signer.assert_awaited_once() + assert attempts == 2 + + +@pytest.mark.parametrize("mode", ["buffered", "stream", "cursor"]) +async def test_unbuffered_consumed_body_is_not_paid_or_retried(mode, signer): + source = Source(b"data") + + async def handler(reader, writer): + request_headers = await headers(reader) + await allow_upload(writer) + assert await body(reader, request_headers) == b"data" + await respond(writer, 402, CHALLENGE) + + async with serve(handler) as url: + with pytest.raises( + LivepeerGatewayError, match="no longer replayable" + ) as error: + payload = StreamPayload(source=source, retry_buffer=False) + if mode == "cursor": + await RunnerSelectionCursor([candidate(url)], body=payload, signer_url="http://signer.example").next() + else: + await call_runner(url, payload=payload, signer_url="http://signer.example", payment_unit="fixed", stream=mode == "stream") + assert isinstance(error.value.__cause__, LivepeerHTTPError) + signer.assert_not_called() + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_upload_cancellation_releases_cache(stream): + read_started = asyncio.Event() + + async def source(): + yield b"prefix" + read_started.set() + await asyncio.Future() + + async def handler(reader, writer): + await headers(reader) + await allow_upload(writer) + assert await read_chunk(reader) == b"prefix" + await reader.read() + + states = [] + make_state = live_runner._stream_state + + def capture_state(payload): + state = make_state(payload) + states.append(state) + return state + + with mock.patch.object(live_runner, "_stream_state", side_effect=capture_state): + async with serve(handler) as url: + task = asyncio.create_task( + call_runner(url, payload=source(), stream=stream) + ) + await read_started.wait() + cache = states[0].cache + assert cache is not None + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert states[0].closed and cache.closed + assert states[0]._writer is states[0]._pending is None + + +async def test_response_stream_owns_active_upload_until_close(): + read_started = asyncio.Event() + response_closed = asyncio.Event() + + async def source(): + yield b"prefix" + read_started.set() + await asyncio.Future() + + async def handler(reader, writer): + await headers(reader) + await allow_upload(writer) + assert await read_chunk(reader) == b"prefix" + await read_started.wait() + # Send response headers while the request source is still live. + writer.write(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n") + await writer.drain() + await reader.read() + response_closed.set() + + async with serve(handler) as url: + result = await call_runner(url, payload=source(), stream=True) + state = result._payload_state + cache = state.cache + assert not state.closed + await result.aclose() + assert state.closed and cache.closed + await asyncio.wait_for(response_closed.wait(), 1) + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_response_mode_controls_timeout_after_continue(stream): + async def source(): + await asyncio.sleep(0.1) + yield b"delayed" + + async def handler(reader, writer): + request_headers = await headers(reader) + # Native aiohttp also accepts 103 as permission; exercise that here + # while checking that permission ends only the continue-wait deadline. + writer.write(b"HTTP/1.1 103 Early Hints\r\n\r\n") + await writer.drain() + assert await body(reader, request_headers) == b"delayed" + await respond(writer) + + async with serve(handler) as url: + if stream: + async with await call_runner( + url, payload=source(), stream=True, timeout=0.05 + ) as result: + assert json.loads(b"".join([chunk async for chunk in result.aiter_bytes()])) == {"ok": True} + else: + with pytest.raises(LivepeerGatewayError): + await call_runner(url, payload=source(), timeout=0.05) diff --git a/tests/test_live_runner.py b/tests/test_live_runner.py index 3d542fd..e54ba4f 100644 --- a/tests/test_live_runner.py +++ b/tests/test_live_runner.py @@ -254,7 +254,7 @@ async def test_call_runner_can_attach_runner_instance(self) -> None: runner_id="runner-1", mode="persistent", orchestrator_url="https://service.example.com", - raw={"label": "echo"}, + raw={"app": "livepeer-sample/echo"}, ) def _request_body( diff --git a/tests/test_runner_payload.py b/tests/test_runner_payload.py new file mode 100644 index 0000000..6064a69 --- /dev/null +++ b/tests/test_runner_payload.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import asyncio +import io +import tempfile +import threading +from unittest import mock + +import pytest + +from livepeer_gateway import StreamPayload +from livepeer_gateway.errors import LivepeerGatewayError +from livepeer_gateway.runner_payload import _StreamState + + +@pytest.fixture +async def make_state(): + states = [] + + def make(source, **options): + state = _StreamState(StreamPayload(source=source, **options)) + states.append(state) + return state + + yield make + for state in states: + await state.aclose() + + +async def _chunks(*chunks): + for chunk in chunks: + yield chunk + + +async def collect(state): + return b"".join([chunk async for chunk in state.chunks()]) + + +@pytest.mark.parametrize("memory,total", [(0, 1), (1, 0), (2, 1), (-1, 10)]) +def test_invalid_buffer_limits(memory, total): + with pytest.raises(ValueError, match="limits"): + StreamPayload( + source=io.BytesIO(), + retry_buffer_memory_limit=memory, + retry_buffer_limit=total, + ) + + +def test_text_files_are_rejected(): + with pytest.raises(TypeError, match="binary"): + StreamPayload(source=io.StringIO("text")) + + +async def test_spills_and_replays_without_rereading_source(make_state): + chunks = [b"one", b"two", b"three"] + reads = [] + + async def source(): + for chunk in chunks: + reads.append(chunk) + yield chunk + + state = make_state(source(), retry_buffer_memory_limit=4, retry_buffer_limit=100) + assert await collect(state) == b"onetwothree" + cache = state.cache + assert cache._rolled + assert await collect(state) == b"onetwothree" + assert reads == chunks + await state.aclose() + assert cache.closed + + +@pytest.mark.parametrize("length,replayable", [(5, True), (6, False)]) +async def test_total_limit_is_checked_before_writing(length, replayable, make_state): + state = make_state(_chunks(b"x" * length), retry_buffer_memory_limit=2, retry_buffer_limit=5) + assert await collect(state) == b"x" * length + assert state.replayable is replayable + if replayable: + assert state.cache_size == 5 + assert await collect(state) == b"x" * length + else: + assert state.cache is None + with pytest.raises(LivepeerGatewayError, match="no longer replayable"): + await collect(state) + + +async def test_cancelled_attempt_replays_prefix_and_adopts_pending_read(make_state): + read_started = asyncio.Event() + finish_read = asyncio.Event() + reads = [] + + async def source(): + for chunk in (b"prefix", b"middle", b"end"): + reads.append(chunk) + if chunk == b"middle": + read_started.set() + await finish_read.wait() + yield chunk + + state = make_state(source()) + first = state.chunks() + assert await anext(first) == b"prefix" + upload = asyncio.create_task(anext(first)) + await read_started.wait() + upload.cancel() + with pytest.raises(asyncio.CancelledError): + await upload + assert not state._pending.cancelled() + # More than one interrupted attempt may replay the same prefix while + # the *one* source read remains pending. + second = state.chunks() + assert await anext(second) == b"prefix" + await second.aclose() + third = state.chunks() + assert await anext(third) == b"prefix" + finish_read.set() + assert b"".join([chunk async for chunk in third]) == b"middleend" + assert reads == [b"prefix", b"middle", b"end"] + + +async def test_pending_overflow_preserves_prefix_for_current_retry(make_state): + read_started = asyncio.Event() + finish_read = asyncio.Event() + prefix = b"x" * (64 * 1024 + 1) + + async def source(): + yield prefix + read_started.set() + await finish_read.wait() + yield b"new-data" + yield b"remainder" + + state = make_state(source(), retry_buffer_memory_limit=4, retry_buffer_limit=len(prefix)) + first = state.chunks() + assert await anext(first) == prefix + task = asyncio.create_task(anext(first)) + await read_started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + retry = state.chunks() + assert await anext(retry) == prefix[:-1] + finish_read.set() + # The pending chunk may complete before this retry finishes its cached prefix. + await asyncio.shield(state._pending) + assert b"".join([chunk async for chunk in retry]) == b"xnew-dataremainder" + assert not state.replayable + assert state.cache is None + + +@pytest.mark.parametrize("failure", ["create", "write", "rollover"]) +async def test_cache_failure_allows_current_upload_to_finish(failure, make_state): + state = make_state(_chunks(b"first", b"second"), retry_buffer_memory_limit=6, retry_buffer_limit=100) + factory = tempfile.SpooledTemporaryFile + + def make_cache(*args, **kwargs): + if failure == "create": + raise OSError("no space") + cache = factory(*args, **kwargs) + setattr(cache, failure, mock.Mock(side_effect=OSError("no space"))) + return cache + + with mock.patch( + "livepeer_gateway.runner_payload.tempfile.SpooledTemporaryFile", + side_effect=make_cache, + ): + assert await collect(state) == b"firstsecond" + assert not state.replayable + assert state.cache is None + + +async def test_failed_source_is_not_retried(make_state): + async def source(): + yield b"prefix" + raise RuntimeError("source failed") + + state = make_state(source()) + with pytest.raises(RuntimeError, match="source failed"): + await collect(state) + assert not state.replayable + + +async def test_closed_unread_file_is_not_retried(make_state): + file = io.BytesIO(b"data") + state = make_state(file) + file.close() + assert not state.replayable + with pytest.raises(LivepeerGatewayError, match="closed"): + await collect(state) + + +async def test_non_bytes_source_fails_clearly(make_state): + state = make_state(_chunks("text")) + with pytest.raises(TypeError, match="produce bytes"): + await collect(state) + + +async def test_replay_read_failure_disables_future_retries(make_state): + state = make_state(_chunks(b"data")) + assert await collect(state) == b"data" + state.cache.read = mock.Mock(side_effect=OSError("storage unavailable")) + with pytest.raises( + LivepeerGatewayError, match="Cannot read upload replay buffer" + ): + await collect(state) + assert not state.replayable and state.cache is None + + +async def test_failed_rewind_disables_future_retries(make_state): + source = io.BytesIO(b"data") + state = make_state(source, retry_buffer=False) + assert await collect(state) == b"data" + source.seek = mock.Mock(side_effect=OSError("seek failed")) + with pytest.raises(LivepeerGatewayError, match="Cannot rewind upload source"): + await collect(state) + assert not state.replayable + source.close() + + +@pytest.mark.parametrize("fail", [False, True]) +async def test_cancellation_joins_cache_write_before_retry(make_state, fail): + state = make_state(_chunks(b"prefix", b"middle", b"tail"), retry_buffer_memory_limit=2) + attempt = state.chunks() + assert await anext(attempt) == b"prefix" + cache = state.cache + entered, finish = asyncio.Event(), threading.Event() + loop = asyncio.get_running_loop() + write = cache.write + + def blocked_write(chunk): + loop.call_soon_threadsafe(entered.set) + assert finish.wait(2), "test did not release the cache write" + if fail: + raise OSError("disk full") + return write(chunk) + + with mock.patch.object(cache, "write", side_effect=blocked_write): + upload = asyncio.create_task(anext(attempt)) + try: + await asyncio.wait_for(entered.wait(), 1) + upload.cancel() + await asyncio.sleep(0) + assert not upload.done() + finally: + finish.set() + with pytest.raises(asyncio.CancelledError): + await upload + if fail: + assert not state.replayable and cache.closed + else: + assert await collect(state) == b"prefixmiddletail" + + +async def test_memory_cache_does_not_dispatch_file_io(make_state): + state = make_state(_chunks(b"one", b"two")) + with mock.patch("asyncio.to_thread", side_effect=AssertionError("unexpected disk I/O")): + assert await collect(state) == await collect(state) == b"onetwo" + await state.aclose() diff --git a/tests/test_selection.py b/tests/test_selection.py index e56d371..863bfd3 100644 --- a/tests/test_selection.py +++ b/tests/test_selection.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from unittest import mock import pytest @@ -51,7 +52,7 @@ async def _call_runner( assert candidate.runner_id == "runner-c" assert candidate.mode == "persistent" assert candidate.orchestrator_url == "https://orch-b" - assert candidate.raw["label"] == "runner-c-label" + assert candidate.raw["metadata"] == "runner-c-metadata" assert candidate.price_info is not None assert candidate.price_info.price == 25 assert candidate.price_info.currency == "wei" @@ -499,7 +500,7 @@ def _discovery_entries() -> list[dict[str, object]]: "app": "app-c", "runner_id": "runner-c", "mode": "persistent", - "label": "runner-c-label", + "metadata": "runner-c-metadata", "price_info": { "price": 25, "currency": "wei", @@ -510,3 +511,68 @@ def _discovery_entries() -> list[dict[str, object]]: address="https://orch-b", ), ] + + +@pytest.mark.parametrize("body", [123, mock.Mock(read=mock.AsyncMock())]) +async def test_invalid_body_fails_before_attempting_candidates(body): + runner = LiveRunnerInstance("http://unused", "test", "runner", "single-shot", "http://unused", {}) + cursor = selection.RunnerSelectionCursor([runner, runner], body=body) + with mock.patch.object(selection, "call_runner") as call: + with pytest.raises(TypeError): + await cursor.next() + call.assert_not_called() + assert cursor.rejections == [] + + +async def test_cursor_cleanup_failure_preserves_original_error(): + cursor = selection.RunnerSelectionCursor([]) + with mock.patch.object(cursor, "aclose", side_effect=RuntimeError("cleanup failed")): + with pytest.raises(NoRunnerAvailableError): + await cursor.next() + + +async def test_overlapping_next_does_not_interrupt_or_advance_cursor(): + runner = LiveRunnerInstance("http://unused", "test", "runner", "single-shot", "http://unused", {}) + result = LiveRunnerCallResult({"ok": True}, runner_url=runner.url, runner=runner) + started, finish = asyncio.Event(), asyncio.Event() + + async def call(**kwargs): + started.set() + await finish.wait() + return result + + async with selection.RunnerSelectionCursor([runner, runner]) as cursor: + with mock.patch.object(selection, "call_runner", side_effect=call) as request: + active = asyncio.create_task(cursor.next()) + try: + await asyncio.wait_for(started.wait(), 1) + with pytest.raises(LivepeerGatewayError, match="already running"): + await asyncio.wait_for(cursor.next(), 1) + assert not active.done() + finally: + finish.set() + assert await active is result + assert await cursor.next() is result + assert request.await_count == 2 + + +async def test_cursor_cancellation_closes_upload_cache(): + async def source(): + yield b"data" + + started = asyncio.Event() + cursor = selection.RunnerSelectionCursor([LiveRunnerInstance("http://unused", "test", "runner", "single-shot", "http://unused", {})], body=source()) + + async def call(**kwargs): + assert await anext(kwargs["payload"].chunks()) == b"data" + started.set() + await asyncio.Future() + + with mock.patch.object(selection, "call_runner", side_effect=call): + attempt = asyncio.create_task(cursor.next()) + await asyncio.wait_for(started.wait(), 1) + cache = cursor._payload_state.cache + attempt.cancel() + with pytest.raises(asyncio.CancelledError): + await attempt + assert cache.closed and cursor._payload_state.closed From 76a1a1c58656ef2ad0c28542ea5f99086b76db69 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Wed, 21 Oct 2026 16:24:18 -0700 Subject: [PATCH 2/7] Add payment session callback for runner calls (#81) Support sync and async on_payment_session callbacks in call_runner and expose LivePaymentSession.manifest_id for payment attribution. Invoke the callback for each new session. Validate segCreds when parsing signer responses and remove redundant caller checks. Cover callback ordering, failures, and cancellation. --- CHANGELOG.md | 2 + src/livepeer_gateway/__init__.py | 2 + src/livepeer_gateway/live_runner.py | 57 ++++----- src/livepeer_gateway/remote_signer.py | 11 +- tests/test_call_runner_uploads.py | 14 ++- tests/test_live_payment_session.py | 1 + tests/test_live_runner.py | 171 +++++++++++++++++++++++++- 7 files changed, 211 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d30d86..c519661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +- `call_runner(on_payment_session=...)` callback for each new payment session +- `LivePaymentSession.manifest_id` for payment attribution - Flexible request body types on call_runner and selectors. ### Changed diff --git a/src/livepeer_gateway/__init__.py b/src/livepeer_gateway/__init__.py index da6d233..ad4e80a 100644 --- a/src/livepeer_gateway/__init__.py +++ b/src/livepeer_gateway/__init__.py @@ -27,6 +27,7 @@ ) from .errors import RunnerRejection from .live_runner import ( + LivePaymentSessionCallback, LiveRunnerCallResult, LiveRunnerCallStream, LiveRunnerGPU, @@ -79,6 +80,7 @@ "LiveRunnerSessionEvent", "LivePaymentChallenge", "LivePaymentSession", + "LivePaymentSessionCallback", "LiveRunnerProxy", "RunnerPayload", "StreamPayload", diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 6bcb280..82c9e77 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -31,7 +31,6 @@ from .errors import LivepeerGatewayError, LivepeerHTTPError, SignerRefreshRequired from .http import _post_empty, _request_body, open_stream, post_json, request_json from .remote_signer import ( - GetPaymentResponse, LivePaymentChallenge, LivePaymentSession, _freeze_headers, @@ -89,6 +88,7 @@ class LiveRunnerSessionEvent: LiveRunnerSessionCallback = Callable[[LiveRunnerSessionEvent], None | Awaitable[None]] +LivePaymentSessionCallback = Callable[[LivePaymentSession], None | Awaitable[None]] @dataclass(frozen=True) @@ -768,6 +768,7 @@ async def call_runner( payment_unit: str | None = ..., timeout: float = ..., max_payment_challenge_retries: int = ..., + on_payment_session: LivePaymentSessionCallback | None = ..., stream: Literal[False] = False, ) -> LiveRunnerCallResult: pass @@ -786,6 +787,7 @@ async def call_runner( payment_unit: str | None = ..., timeout: float = ..., max_payment_challenge_retries: int = ..., + on_payment_session: LivePaymentSessionCallback | None = ..., stream: Literal[True], ) -> LiveRunnerCallStream: pass @@ -803,6 +805,7 @@ async def call_runner( payment_unit: str | None = None, timeout: float = 5.0, max_payment_challenge_retries: int = 3, + on_payment_session: LivePaymentSessionCallback | None = None, stream: bool = False, ) -> LiveRunnerCallResult | LiveRunnerCallStream: """Call a runner once and return its result (or a live stream if ``stream=True``). @@ -811,6 +814,10 @@ async def call_runner( paid via the signer and retried (up to ``max_payment_challenge_retries``), one job, one upfront payment. Raises ``LivepeerHTTPError`` on non-402 errors. + After a new payment session is obtained, `on_payment_session` is called. + Metered renewals do not invoke it. The callback may be sync or + async. Its exceptions propagate and abort the call. + ``application/json`` and ``+json`` types parse into ``result.data``; anything else (an image, ndjson) comes back unparsed in ``result.content`` + ``result.content_type``. @@ -852,15 +859,16 @@ async def call_runner( request_headers[_LIVE_RUNNER_PAYER_ADDRESS_HEADER] = payer_address # Pending challenge means payment is needed. if challenge is not None: + payment_session = LivePaymentSession( + signer_url=signer_url or "", + signer_headers=signer_headers, + type=payment_type, + challenge=challenge, + app=runner.app if runner is not None else None, + max_price=max_price.to_json() if max_price is not None else None, + ) try: - payment_session, payment = await _get_runner_payment( - challenge, - payment_type=payment_type, - app=runner.app if runner is not None else None, - signer_url=signer_url or "", - signer_headers=signer_headers, - max_price=max_price, - ) + payment = await payment_session.get_payment() except SignerRefreshRequired as e: if attempt + 1 >= attempts: raise @@ -871,8 +879,12 @@ async def call_runner( ) challenge = None continue + if on_payment_session is not None: + result = on_payment_session(payment_session) + if inspect.isawaitable(result): + await result request_headers["Livepeer-Payment"] = payment.payment - request_headers["Livepeer-Segment"] = payment.seg_creds or "" + request_headers["Livepeer-Segment"] = payment.seg_creds session_id = challenge.manifest_id # Metered pricing bills for as long as the work runs. @@ -1012,31 +1024,6 @@ async def _stop_funding(task: asyncio.Task[None] | None) -> None: await task -async def _get_runner_payment( - challenge: LivePaymentChallenge, - *, - payment_type: str, - signer_url: str, - signer_headers: dict[str, str] | None, - max_price: LiveRunnerPriceInfo | None, - app: str | None = None, -) -> tuple[LivePaymentSession, GetPaymentResponse]: - session = LivePaymentSession( - signer_url=signer_url, - signer_headers=signer_headers, - type=payment_type, - challenge=challenge, - app=app, - max_price=max_price.to_json() if max_price is not None else None, - ) - payment = await session.get_payment() - if not payment.payment: - raise LivepeerGatewayError("Live runner payment response missing payment") - if not payment.seg_creds: - raise LivepeerGatewayError("Live runner payment response missing segCreds") - return session, payment - - def _runner_payment_type( runner: LiveRunnerInstance | None, payment_unit: str | None = None, diff --git a/src/livepeer_gateway/remote_signer.py b/src/livepeer_gateway/remote_signer.py index 1c027bf..6196aea 100644 --- a/src/livepeer_gateway/remote_signer.py +++ b/src/livepeer_gateway/remote_signer.py @@ -131,6 +131,11 @@ def __init__( self._max_refresh_retries = max(0, int(max_refresh_retries)) self._state: dict[str, Any] | None = None + @property + def manifest_id(self) -> str: + """The manifest ID of the payment challenge for this session.""" + return self._challenge.manifest_id + async def get_payment(self) -> GetPaymentResponse: if not self._signer_url: return GetPaymentResponse(payment="", seg_creds=None) @@ -161,10 +166,6 @@ async def send_payment(self) -> None: from .http import _post_empty payment = await self.get_payment() - if not payment.seg_creds: - # An empty segment header fails the orchestrator's sig check and - # comes back 403, which reads as a dead session, not a bad signer. - raise PaymentError("Signer returned a payment with no segCreds") headers = { "Livepeer-Payment": payment.payment, "Livepeer-Segment": payment.seg_creds, @@ -220,7 +221,7 @@ async def _payment_request(self) -> GetPaymentResponse: ) seg_creds = data.get("segCreds") - if seg_creds is not None and not isinstance(seg_creds, str): + if not isinstance(seg_creds, str) or not seg_creds: raise PaymentError( f"GetPayment error: invalid 'segCreds' in response (url={url})" ) diff --git a/tests/test_call_runner_uploads.py b/tests/test_call_runner_uploads.py index b8e9364..21a6c75 100644 --- a/tests/test_call_runner_uploads.py +++ b/tests/test_call_runner_uploads.py @@ -33,8 +33,8 @@ @pytest.fixture def signer(monkeypatch): monkeypatch.setattr(live_runner, "get_signer_info", mock.AsyncMock(return_value=SimpleNamespace(address="payer"))) - payment = mock.AsyncMock(return_value=(None, SimpleNamespace(payment="paid", seg_creds="segment"))) - monkeypatch.setattr(live_runner, "_get_runner_payment", payment) + payment = mock.AsyncMock(return_value={"payment": "paid", "segCreds": "segment", "state": {}}) + monkeypatch.setattr("livepeer_gateway.http.post_json", payment) return payment @@ -207,6 +207,7 @@ async def test_early_payment_challenge_does_not_touch_source( ): source = Source(b"one", b"two") attempts = 0 + sessions = [] async def handler(reader, writer): nonlocal attempts @@ -219,6 +220,7 @@ async def handler(reader, writer): assert await reader.read() == b"" return assert request_headers["Livepeer-Payment"] == "paid" + assert [session.manifest_id for session in sessions] == [CHALLENGE["manifest_id"]] assert source.iterations == source.reads == 0 await allow_upload(writer) assert await body(reader, request_headers) == b"onetwo" @@ -234,6 +236,7 @@ async def handler(reader, writer): payload=StreamPayload(source=source, retry_buffer=False), signer_url="http://signer.example", payment_unit="fixed", + on_payment_session=sessions.append, stream=stream, ), 2, @@ -244,10 +247,9 @@ async def handler(reader, writer): assert source.reads == 3 assert not source.closed signer.assert_awaited_once() - challenge = signer.call_args.args[0] - assert challenge.payment_params == CHALLENGE["payment_params"] - assert challenge.manifest_id == CHALLENGE["manifest_id"] - assert challenge.payment_url == CHALLENGE["payment_url"] + payment_request = signer.call_args.args[1] + assert payment_request["orchestrator"] == CHALLENGE["payment_params"] + assert payment_request["ManifestID"] == CHALLENGE["manifest_id"] @pytest.mark.parametrize("status,kind", [(402, "nonseekable"), (503, "iterator"), (307, "file")]) diff --git a/tests/test_live_payment_session.py b/tests/test_live_payment_session.py index e96af45..d07bffe 100644 --- a/tests/test_live_payment_session.py +++ b/tests/test_live_payment_session.py @@ -268,6 +268,7 @@ async def _post_json( assert calls[4][1]["orchestrator"] == "new-payment-params" assert session._challenge.payment_url == _PAYMENT_URL + assert session.manifest_id == "manifest-1" async def test_get_signer_info_caches_result(self) -> None: calls: list[tuple[str, dict[str, object]]] = [] diff --git a/tests/test_live_runner.py b/tests/test_live_runner.py index e54ba4f..9492757 100644 --- a/tests/test_live_runner.py +++ b/tests/test_live_runner.py @@ -26,7 +26,7 @@ stop_runner_session, create_proxy, ) -from livepeer_gateway.remote_signer import LivePaymentChallenge +from livepeer_gateway.remote_signer import LivePaymentChallenge, LivePaymentSession class TestLiveRunnerHelpers: @@ -631,6 +631,175 @@ def _request_body( assert payment_attempts == 2 +class TestPaymentSessionCallback: + @pytest.fixture + def payment_call(self, request): + url = "https://runner.example.com/call" + stream = getattr(request, "param", False) + reply = ( + ( + SimpleNamespace(close=mock.AsyncMock()), + SimpleNamespace(status=200, headers={}, _writer=None, close=mock.Mock()), + ) + if stream + else _json_data({"ok": True}) + ) + + def challenge(manifest): + return LivepeerHTTPError(402, url, _payment_challenge_body(manifest)) + + transport = mock.AsyncMock(side_effect=[challenge("manifest-1"), reply]) + signer = mock.AsyncMock( + return_value={"payment": "payment", "segCreds": "segment", "state": {}} + ) + with ( + mock.patch.object( + live_runner, "open_stream" if stream else "_request_body", transport + ), + mock.patch.object( + live_runner, + "get_signer_info", + mock.AsyncMock(return_value=SimpleNamespace(address="payer")), + ), + mock.patch("livepeer_gateway.http.post_json", signer), + ): + yield SimpleNamespace( + kwargs=dict( + runner_url=url, + signer_url="https://signer.example.com", + payment_unit="fixed", + stream=stream, + ), + transport=transport, + signer=signer, + reply=reply, + challenge=challenge, + ) + + @pytest.mark.parametrize( + "payment_call,async_callback,payment_unit", + [(False, False, "fixed"), (True, True, "seconds")], + indirect=["payment_call"], + ) + async def test_reports_each_successful_session_before_dispatch( + self, payment_call, async_callback, payment_unit + ) -> None: + call = payment_call + call.kwargs["payment_unit"] = payment_unit + call.signer.side_effect = [SignerRefreshRequired("refresh")] + [ + call.signer.return_value + ] * 2 + call.transport.side_effect = [ + call.challenge("refused"), + call.challenge("manifest-1"), + call.challenge("manifest-2"), + call.reply, + ] + sessions: list[LivePaymentSession] = [] + + def collect(session): + assert ( + call.signer.await_count + == call.transport.await_count + == len(sessions) + 2 + ) + sessions.append(session) + + callback = mock.AsyncMock(side_effect=collect) if async_callback else collect + result = await call_runner(**call.kwargs, on_payment_session=callback) + if call.kwargs["stream"]: + await result.aclose() + assert [s.manifest_id for s in sessions] == ["manifest-1", "manifest-2"] + assert result.payment_session is ( + sessions[-1] if payment_unit == "seconds" else None + ) + assert call.transport.await_count == 4 + + @pytest.mark.parametrize( + "error", + [ + RuntimeError("failed"), + SignerRefreshRequired("failed"), + LivepeerHTTPError(402, "https://callback.example.com"), + ], + ) + async def test_callback_errors_abort_without_retry( + self, payment_call, error + ) -> None: + callback = mock.AsyncMock(side_effect=error) + with pytest.raises(type(error)) as raised: + await call_runner(**payment_call.kwargs, on_payment_session=callback) + assert raised.value is error + assert raised.value.__context__ is None + assert ( + payment_call.transport.await_count == payment_call.signer.await_count == 1 + ) + + @pytest.mark.parametrize( + "outcome", + [ + LivepeerHTTPError(403, "https://signer.example.com"), + {"segCreds": "segment", "state": {}}, + {"payment": "payment", "state": {}}, + {"payment": "payment", "segCreds": None, "state": {}}, + {"payment": "payment", "segCreds": "", "state": {}}, + {"payment": "payment", "segCreds": 123, "state": {}}, + ], + ) + async def test_signer_failure_does_not_report(self, payment_call, outcome) -> None: + if isinstance(outcome, Exception): + payment_call.signer.side_effect = outcome + else: + payment_call.signer.return_value = outcome + callback = mock.Mock() + with pytest.raises(LivepeerGatewayError): + await call_runner(**payment_call.kwargs, on_payment_session=callback) + callback.assert_not_called() + assert payment_call.transport.await_count == 1 + + async def test_paid_request_failure_preserves_notification( + self, payment_call + ) -> None: + call = payment_call + error = LivepeerHTTPError(500, "https://runner.example.com") + call.transport.side_effect = [call.challenge("manifest-1"), error] + sessions: list[LivePaymentSession] = [] + with pytest.raises(LivepeerHTTPError) as raised: + await call_runner(**call.kwargs, on_payment_session=sessions.append) + assert raised.value is error + assert [s.manifest_id for s in sessions] == ["manifest-1"] + + @pytest.mark.parametrize("phase", ["signing", "callback"]) + async def test_cancellation_remains_prompt(self, payment_call, phase) -> None: + call = payment_call + entered, release = asyncio.Event(), asyncio.Event() + + async def block(*_args, **_kwargs): + entered.set() + await release.wait() + return call.signer.return_value + + callback = mock.AsyncMock(side_effect=block if phase == "callback" else None) + if phase == "signing": + call.signer.side_effect = block + task = asyncio.create_task( + call_runner(**call.kwargs, on_payment_session=callback) + ) + try: + await asyncio.wait_for(entered.wait(), timeout=1) + task.cancel() + done, _ = await asyncio.wait([task], timeout=1) + assert task in done + with pytest.raises(asyncio.CancelledError): + await task + assert callback.await_count == (1 if phase == "callback" else 0) + assert call.transport.await_count == 1 + finally: + release.set() + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + def _payment_challenge_body(manifest_id: str) -> str: return json.dumps( { From 61061410ce760eee4e680f3447cc6982cae667d7 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Fri, 23 Oct 2026 14:46:24 -0700 Subject: [PATCH 3/7] Add session stop API, store session tokens (#83) Store token from `reserved` events and make them available to session events. Add LiveRunnerRegistration.stop_session() so apps can stop sessions before receiving application traffic. --- CHANGELOG.md | 2 + src/livepeer_gateway/live_runner.py | 38 ++++++++---- tests/test_live_runner.py | 96 +++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c519661..e1added 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +- `LiveRunnerRegistration.stop_session()` to stop reserved sessions +- `LiveRunnerSessionEvent.session_token` field for session management - `call_runner(on_payment_session=...)` callback for each new payment session - `LivePaymentSession.manifest_id` for payment attribution - Flexible request body types on call_runner and selectors. diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 82c9e77..48e77ed 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -85,6 +85,7 @@ class LiveRunnerSessionEvent: event: Literal["reserved", "released"] timestamp: str | None raw: dict[str, Any] + session_token: str = "" LiveRunnerSessionCallback = Callable[[LiveRunnerSessionEvent], None | Awaitable[None]] @@ -318,7 +319,7 @@ def __init__( self._unregister_on_close = unregister_on_close self._on_session_reserve = on_session_reserve self._on_session_release = on_session_release - self._active_session_ids: list[str] = [] + self._active_sessions: dict[str, str] = {} self.o2r_channel: LiveRunnerTrickleChannel | None = None self._o2r_reader: ChannelReader | None = None self._closed = False @@ -332,8 +333,7 @@ async def start(self) -> LiveRunnerRegistration: @property def active_session_ids(self) -> tuple[str, ...]: - # Return an immutable snapshot; internal storage stays list-backed to preserve reservation order. - return tuple(self._active_session_ids) + return tuple(self._active_sessions) async def close(self) -> None: self._closed = True @@ -379,6 +379,23 @@ async def __aenter__(self) -> LiveRunnerRegistration: async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None: await self.close() + async def stop_session(self, session_id: str) -> None: + """Stop a reserved session using its cached token.""" + session_id = session_id.strip() + token = self._active_sessions.get(session_id) + if not token: + raise LivepeerGatewayError(f"Live runner session {session_id!r} has no session token") + await _post_empty( + _join_endpoint( + self.orchestrator_url, + f"/runner/{quote(self.runner_id, safe='')}/session/{quote(session_id, safe='')}/stop", + ), + headers={"Livepeer-Session-Token": token}, + timeout=self._timeout, + ) + if self._active_sessions.get(session_id) == token: + self._release_session_id(session_id) + async def create_trickle_channels( self, session: str | LiveRunnerSessionRequest, @@ -570,8 +587,12 @@ async def _handle_o2r_message(self, message: dict[str, Any]) -> None: session_id = session_id.strip() typed_event = cast(Literal["reserved", "released"], event) + session_token = "" if typed_event == "reserved": - self._reserve_session_id(session_id) + token = message.get("session_token") + session_token = token.strip() if isinstance(token, str) else "" + if session_token or session_id not in self._active_sessions: + self._active_sessions[session_id] = session_token else: self._release_session_id(session_id) @@ -585,6 +606,7 @@ async def _handle_o2r_message(self, message: dict[str, Any]) -> None: event=typed_event, timestamp=timestamp if isinstance(timestamp, str) else None, raw=message, + session_token=session_token, ) try: result = callback(session_event) @@ -593,14 +615,8 @@ async def _handle_o2r_message(self, message: dict[str, Any]) -> None: except Exception: _LOG.exception("Live runner %s callback failed for session %s", typed_event, session_id) - def _reserve_session_id(self, session_id: str) -> None: - if session_id not in self._active_session_ids: - self._active_session_ids.append(session_id) - def _release_session_id(self, session_id: str) -> None: - try: - self._active_session_ids.remove(session_id) - except ValueError: + if self._active_sessions.pop(session_id, None) is None: _LOG.debug("Live runner session %s was already released", session_id) diff --git a/tests/test_live_runner.py b/tests/test_live_runner.py index 9492757..32a58d6 100644 --- a/tests/test_live_runner.py +++ b/tests/test_live_runner.py @@ -857,7 +857,97 @@ async def close(self, **_kwargs: object) -> None: await asyncio.gather(self._task, return_exceptions=True) +def _registration(*, runner_id: str = "runner-1", **kwargs) -> LiveRunnerRegistration: + return LiveRunnerRegistration( + orchestrator_url="http://localhost:8935", + secret="secret", + runner_id=runner_id, + runner_url="http://localhost:9000", + app="test", + price_info=LiveRunnerPriceInfo(0), + **kwargs, + ) + + class TestLiveRunnerRegistration: + async def test_reserve_callback_can_stop_before_application_request(self) -> None: + released_events: list[LiveRunnerSessionEvent] = [] + + async def on_reserve(event: LiveRunnerSessionEvent) -> None: + assert event.session_token == "session-token" + await reg.stop_session(event.session_id) + + reg = _registration( + runner_id="runner/1", + timeout=12.0, + on_session_reserve=on_reserve, + on_session_release=released_events.append, + ) + reg.orchestrator_url = "https://service.example.com/api" + stop = mock.AsyncMock() + with mock.patch.object(live_runner, "_post_empty", stop): + await reg._handle_o2r_message( + {"event": "reserved", "session": " session/1 ", "session_token": " session-token "} + ) + stop.assert_awaited_once_with( + "https://service.example.com/api/runner/runner%2F1/session/session%2F1/stop", + headers={"Livepeer-Session-Token": "session-token"}, + timeout=12.0, + ) + assert reg.active_session_ids == () + assert released_events == [] + await reg._handle_o2r_message({"event": "released", "session": "session/1"}) + + assert [event.session_id for event in released_events] == ["session/1"] + assert released_events[0].session_token == "" + + async def test_stop_session_failure_retains_credentials(self) -> None: + reg = _registration() + await reg._handle_o2r_message( + {"event": "reserved", "session": "session-1", "session_token": "token"} + ) + with ( + mock.patch.object(live_runner, "_post_empty", side_effect=LivepeerHTTPError(503, "url", "unavailable")), + pytest.raises(LivepeerHTTPError), + ): + await reg.stop_session("session-1") + assert reg._active_sessions == {"session-1": "token"} + + async def test_stop_session_preserves_replacement_reservation(self) -> None: + reg = _registration() + await reg._handle_o2r_message( + {"event": "reserved", "session": "session-1", "session_token": "old-token"} + ) + + async def replace_before_response(*args, **kwargs): + await reg._handle_o2r_message({"event": "released", "session": "session-1"}) + await reg._handle_o2r_message( + {"event": "reserved", "session": "session-1", "session_token": "new-token"} + ) + + with mock.patch.object(live_runner, "_post_empty", side_effect=replace_before_response): + await reg.stop_session("session-1") + assert reg._active_sessions == {"session-1": "new-token"} + + @pytest.mark.parametrize( + "token_fields", [None, {}, {"session_token": " "}], + ids=["unknown-session", "missing-token", "blank-token"], + ) + async def test_stop_session_requires_token( + self, token_fields: dict[str, object] | None + ) -> None: + reg = _registration() + if token_fields is not None: + await reg._handle_o2r_message( + {"event": "reserved", "session": "session-1", **token_fields} + ) + with ( + mock.patch.object(live_runner, "_post_empty") as stop, + pytest.raises(LivepeerGatewayError, match="has no session token"), + ): + await reg.stop_session("session-1") + stop.assert_not_awaited() + async def test_initial_heartbeat_starts_o2r_channel_reader_callback_once( self, ) -> None: @@ -984,12 +1074,17 @@ def _post_json( { "event": "reserved", "session": "session-b", + "session_token": "original-token", "timestamp": "2026-05-20T17:00:00Z", } ) await on_event({"event": "reserved", "session": "session-a"}) + await on_event( + {"event": "reserved", "session": "session-b", "session_token": "refreshed-token"} + ) await on_event({"event": "reserved", "session": "session-b"}) assert reg.active_session_ids == ("session-b", "session-a") + assert reg._active_sessions["session-b"] == "refreshed-token" await on_event( { @@ -1014,6 +1109,7 @@ def _post_json( "session-b", "session-a", "session-b", + "session-b", ] assert sync_events[0].event == "reserved" assert sync_events[0].timestamp == "2026-05-20T17:00:00Z" From 35fc426843c771f04cbc18e05e513218957167a2 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Fri, 23 Oct 2026 16:34:52 -0700 Subject: [PATCH 4/7] Enable Ruff (#84) --- .github/workflows/tests.yml | 2 - pyproject.toml | 2 +- src/livepeer_gateway/channel_reader.py | 2 +- src/livepeer_gateway/channel_writer.py | 8 +-- src/livepeer_gateway/control.py | 6 +- src/livepeer_gateway/media_decode.py | 55 ++++++++--------- src/livepeer_gateway/media_output.py | 21 +++---- src/livepeer_gateway/media_publish.py | 71 +++++++++++----------- src/livepeer_gateway/remote_signer.py | 10 +-- src/livepeer_gateway/segment_reader.py | 14 ++--- src/livepeer_gateway/token.py | 4 +- src/livepeer_gateway/trickle_publisher.py | 42 ++++++------- src/livepeer_gateway/trickle_subscriber.py | 19 +++--- 13 files changed, 126 insertions(+), 130 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 66c8076..9e84691 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,8 +11,6 @@ permissions: jobs: ruff: - # disabled until after a repo-wide ruff pass - if: ${{ false }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 diff --git a/pyproject.toml b/pyproject.toml index c405c6e..6e9a06b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,5 +88,5 @@ required-version = "==0.16.1" target-version = "py312" [tool.ruff.lint] -# Baseline defaults from #56; CI scopes UP diagnostics to changed lines. +# Baseline checks and Python modernization rules, enforced repository-wide. select = ["E4", "E7", "E9", "F", "UP"] diff --git a/src/livepeer_gateway/channel_reader.py b/src/livepeer_gateway/channel_reader.py index 1a4694d..63bf1cb 100644 --- a/src/livepeer_gateway/channel_reader.py +++ b/src/livepeer_gateway/channel_reader.py @@ -267,7 +267,7 @@ def __call__( """ url = self.events_url - async def _read_all(segment: "SegmentReader", *, chunk_size: int = 33 * 1024) -> bytes: + async def _read_all(segment: SegmentReader, *, chunk_size: int = 33 * 1024) -> bytes: parts = [] try: reader = segment.make_reader() diff --git a/src/livepeer_gateway/channel_writer.py b/src/livepeer_gateway/channel_writer.py index c188280..be52675 100644 --- a/src/livepeer_gateway/channel_writer.py +++ b/src/livepeer_gateway/channel_writer.py @@ -3,7 +3,7 @@ import asyncio import json import logging -from typing import Any, Optional, Protocol +from typing import Any, Protocol from .trickle_publisher import SegmentWriter, TricklePublisher @@ -31,12 +31,12 @@ def __init__( self.url = url self._publisher = TricklePublisher(url, mime_type) self._segment_interval = segment_interval - self._writer: Optional[SegmentWriter] = None + self._writer: SegmentWriter | None = None self._lock = asyncio.Lock() - self._rotation_task: Optional[asyncio.Task[None]] = None + self._rotation_task: asyncio.Task[None] | None = None self.start_rotation() - def start_rotation(self) -> Optional[asyncio.Task[None]]: + def start_rotation(self) -> asyncio.Task[None] | None: """ Start periodic segment rotation if running in an event loop. diff --git a/src/livepeer_gateway/control.py b/src/livepeer_gateway/control.py index 4cfc8cf..179e4d6 100644 --- a/src/livepeer_gateway/control.py +++ b/src/livepeer_gateway/control.py @@ -3,7 +3,7 @@ import asyncio import json import logging -from typing import Any, Optional +from typing import Any from .trickle_publisher import ( TricklePublisher, @@ -23,7 +23,7 @@ def __init__( self.control_url = control_url self._publisher = TricklePublisher(control_url, mime_type) self._keepalive_interval_s = 10.0 - self._keepalive_task: Optional[asyncio.Task[None]] = None + self._keepalive_task: asyncio.Task[None] | None = None async def write(self, msg: dict[str, Any]) -> None: """ @@ -58,7 +58,7 @@ async def close(self) -> None: _LOG.exception("Control keepalive task failed during shutdown") await self._publisher.close() - def start_keepalive(self) -> Optional[asyncio.Task[None]]: + def start_keepalive(self) -> asyncio.Task[None] | None: """ Start periodic keepalive messages. diff --git a/src/livepeer_gateway/media_decode.py b/src/livepeer_gateway/media_decode.py index 4413f01..0bd41a8 100644 --- a/src/livepeer_gateway/media_decode.py +++ b/src/livepeer_gateway/media_decode.py @@ -5,7 +5,6 @@ import time from dataclasses import dataclass from fractions import Fraction -from typing import Optional, Union import av @@ -14,11 +13,11 @@ class DecodedMediaFrame: kind: str stream_index: int - frame: Union[av.VideoFrame, av.AudioFrame] - pts: Optional[int] - time_base: Optional[Fraction] + frame: av.VideoFrame | av.AudioFrame + pts: int | None + time_base: Fraction | None # pts_time: presentation timestamp in seconds (pts * time_base). Not wall-clock time. - pts_time: Optional[float] + pts_time: float | None demuxed_at: float decoded_at: float @@ -27,15 +26,15 @@ class DecodedMediaFrame: class VideoDecodedMediaFrame(DecodedMediaFrame): width: int height: int - pix_fmt: Optional[str] + pix_fmt: str | None @dataclass(frozen=True) class AudioDecodedMediaFrame(DecodedMediaFrame): - sample_rate: Optional[int] - layout: Optional[str] - format: Optional[str] - samples: Optional[int] + sample_rate: int | None + layout: str | None + format: str | None + samples: int | None @dataclass(frozen=True) @@ -43,11 +42,11 @@ class DemuxedMediaPacket: kind: str stream_index: int packet: av.Packet - pts: Optional[int] - dts: Optional[int] - time_base: Optional[Fraction] - pts_time: Optional[float] - dts_time: Optional[float] + pts: int | None + dts: int | None + time_base: Fraction | None + pts_time: float | None + dts_time: float | None is_keyframe: bool size: int demuxed_at: float @@ -120,7 +119,7 @@ class _BlockingByteStream: def __init__(self) -> None: # TODO: add backpressure (bounded queue + blocking feed) to avoid unbounded # memory growth if the decoder thread can't keep up with the producer. - self._queue: "queue.Queue[object]" = queue.Queue() + self._queue: queue.Queue[object] = queue.Queue() self._buffer = bytearray() self._closed = False self._total_chunks_fed = 0 @@ -207,7 +206,7 @@ def __init__(self, error: BaseException) -> None: self.error = error -def _fraction_from_time_base(time_base: object) -> Optional[Fraction]: +def _fraction_from_time_base(time_base: object) -> Fraction | None: numerator = getattr(time_base, "numerator", None) denominator = getattr(time_base, "denominator", None) if numerator is not None and denominator is not None: @@ -221,7 +220,7 @@ def _fraction_from_time_base(time_base: object) -> Optional[Fraction]: return None -def _time_from_pts(pts: Optional[int], time_base: Optional[Fraction]) -> Optional[float]: +def _time_from_pts(pts: int | None, time_base: Fraction | None) -> float | None: if pts is None or time_base is None: return None try: @@ -231,7 +230,7 @@ def _time_from_pts(pts: Optional[int], time_base: Optional[Fraction]) -> Optiona def _build_decoded_frame( - frame: Union[av.VideoFrame, av.AudioFrame], + frame: av.VideoFrame | av.AudioFrame, *, stream_index: int, demuxed_at: float, @@ -280,7 +279,7 @@ def _stream_kind(packet: av.Packet) -> str: return "unknown" -def _packet_time_base(packet: av.Packet) -> Optional[Fraction]: +def _packet_time_base(packet: av.Packet) -> Fraction | None: time_base = getattr(packet, "time_base", None) if time_base is None: stream = getattr(packet, "stream", None) @@ -313,7 +312,7 @@ def _build_demuxed_packet( ) -def _item_pts_time(item: object) -> Optional[float]: +def _item_pts_time(item: object) -> float | None: if isinstance(item, (DecodedMediaFrame, DemuxedMediaPacket)): return item.pts_time return None @@ -323,16 +322,16 @@ class _MpegTsOutputWorker: def __init__(self, *, thread_name: str) -> None: self._reader = _BlockingByteStream() # TODO: add backpressure here too (bounded queue) if callers are slow to consume. - self._output: "queue.Queue[object]" = queue.Queue() + self._output: queue.Queue[object] = queue.Queue() self._total_output_items_enqueued = 0 self._total_output_items_dequeued = 0 self._output_wait_s = 0.0 # Aggregate source-PTS media-time watermarks across all streams for # output-queue telemetry. Best-effort snapshots updated from producer # (decoder thread) and consumer (get()) without per-op locks. - self._max_pts_time_enqueued: Optional[float] = None - self._max_pts_time_dequeued: Optional[float] = None - self._min_pts_time_dequeued: Optional[float] = None + self._max_pts_time_enqueued: float | None = None + self._max_pts_time_dequeued: float | None = None + self._min_pts_time_dequeued: float | None = None self._stop = threading.Event() self._thread = threading.Thread(target=self._run, name=thread_name, daemon=True) @@ -419,7 +418,7 @@ def __init__(self) -> None: super().__init__(thread_name="MpegTsDecoder") def _run(self) -> None: - container: Optional[av.container.InputContainer] = None + container: av.container.InputContainer | None = None try: container = av.open(self._reader, format="mpegts", mode="r") for packet in container.demux(): @@ -459,7 +458,7 @@ def __init__(self) -> None: super().__init__(thread_name="MpegTsPacketDemuxer") def _run(self) -> None: - container: Optional[av.container.InputContainer] = None + container: av.container.InputContainer | None = None try: container = av.open(self._reader, format="mpegts", mode="r") for packet in container.demux(): @@ -488,7 +487,7 @@ def is_decoder_end(item: object) -> bool: return item is _END -def decoder_error(item: object) -> Optional[BaseException]: +def decoder_error(item: object) -> BaseException | None: if isinstance(item, _DecoderError): return item.error return None diff --git a/src/livepeer_gateway/media_output.py b/src/livepeer_gateway/media_output.py index a634304..54bd6ff 100644 --- a/src/livepeer_gateway/media_output.py +++ b/src/livepeer_gateway/media_output.py @@ -12,7 +12,6 @@ from enum import Enum from contextlib import suppress from collections.abc import AsyncIterator, Awaitable, Callable, Collection -from typing import Optional from .errors import LivepeerGatewayError from .media_decode import ( @@ -71,8 +70,8 @@ class MediaOutputStats: # decoder: decode pipeline queue metrics, including the distinction between # queued_bytes in the cross-thread input queue and buffered_bytes already # staged in the internal bytearray used to satisfy read() calls. - decoder: Optional[DecoderQueueStats] - subscriber: Optional[TrickleSubscriberStats] + decoder: DecoderQueueStats | None + subscriber: TrickleSubscriberStats | None def __str__(self) -> str: return ( @@ -140,7 +139,7 @@ def __init__( *, start_seq: int = -2, max_retries: int = 5, - max_segment_bytes: Optional[int] = None, + max_segment_bytes: int | None = None, connection_close: bool = False, chunk_size: int = 64 * 1024, max_segments: int = 5, @@ -165,15 +164,15 @@ def __init__( self.on_frame = on_frame self.on_packet = on_packet - self._sub: Optional[TrickleSubscriber] = None + self._sub: TrickleSubscriber | None = None self._segments: list[SegmentReader] = [] self._lock = asyncio.Lock() self._eos = False self._next_local_seq = 0 self._base_seq = 0 self._started_at = time.time() - self._processor: Optional[MpegTsDecoder | MpegTsPacketDemuxer] = None - self._last_decoder_stats: Optional[DecoderQueueStats] = None + self._processor: MpegTsDecoder | MpegTsPacketDemuxer | None = None + self._last_decoder_stats: DecoderQueueStats | None = None self._bytes_callback_task: asyncio.Task[None] | None = None self._frame_callback_task: asyncio.Task[None] | None = None self._packet_callback_task: asyncio.Task[None] | None = None @@ -481,7 +480,7 @@ async def _iter_bytes( async def _next_segment( self, seq: int, - ) -> Optional[SegmentReader]: + ) -> SegmentReader | None: """ Return the segment at seq, lazily advancing the subscriber if needed. """ @@ -571,7 +570,7 @@ async def close(self, *, wait_callbacks: bool = True, timeout: float | None = 10 if self._callback_errors: raise self._callback_errors[0] - async def __aenter__(self) -> "MediaOutput": + async def __aenter__(self) -> MediaOutput: self.start_callbacks() return self @@ -605,7 +604,7 @@ def get_stats(self) -> MediaOutputStats: ) -def _normalize_content_type(value: Optional[str]) -> Optional[str]: +def _normalize_content_type(value: str | None) -> str | None: if value is None: return None return value.split(";", 1)[0].strip().lower() @@ -624,7 +623,7 @@ def _normalize_accepted_content_types( return normalized -def _require_content_type(value: Optional[str], accepted: frozenset[str]) -> None: +def _require_content_type(value: str | None, accepted: frozenset[str]) -> None: normalized = _normalize_content_type(value) if normalized not in accepted: raise LivepeerGatewayError( diff --git a/src/livepeer_gateway/media_publish.py b/src/livepeer_gateway/media_publish.py index f60de61..1cb8c04 100644 --- a/src/livepeer_gateway/media_publish.py +++ b/src/livepeer_gateway/media_publish.py @@ -9,7 +9,8 @@ import time from dataclasses import dataclass, field from fractions import Fraction -from typing import Optional, Set, Awaitable, Any, BinaryIO +from typing import Any, BinaryIO +from collections.abc import Awaitable import av from av.video.frame import PictureType @@ -47,7 +48,7 @@ def _rescale_pts(pts: int, src_tb: Fraction, dst_tb: Fraction) -> int: return int(round(float((Fraction(pts) * src_tb) / dst_tb))) -def _normalize_fps(fps: Optional[float]) -> int: +def _normalize_fps(fps: float | None) -> int: if fps is None or not math.isfinite(fps) or fps <= 0: fps = 30.0 return max(1, int(round(fps))) @@ -69,7 +70,7 @@ class VideoOutputConfig: # Local queue depth for this track before frames are dropped. queue_size: int = 8 # Target output FPS hint passed to the encoder. - fps: Optional[float] = None + fps: float | None = None # Target GOP/keyframe cadence used for segment boundaries. keyframe_interval_s: float = 2.0 # FFmpeg/PyAV encoder name for this output stream. @@ -96,9 +97,9 @@ class AudioOutputConfig: # FFmpeg/PyAV encoder name for this output stream. codec: str = "libopus" # Target output sample rate. None derives from first frame. - sample_rate: Optional[int] = None + sample_rate: int | None = None # Target output channel layout. None derives from first frame. - layout: Optional[str] = None + layout: str | None = None # Output sample format presented to the encoder. format: str = "flt" @@ -218,12 +219,12 @@ class MediaPublishTrack: def __init__( self, - owner: "MediaPublish", + owner: MediaPublish, *, kind: str, config: VideoOutputConfig | AudioOutputConfig, index: int, - queue: "_FrameQueue", + queue: _FrameQueue, stats: dict[str, int], ) -> None: self._owner = owner @@ -239,14 +240,14 @@ def __init__( self._label: str = "" self._stream: Any = None self._pending_frames: list[av.VideoFrame | av.AudioFrame] = [] - self._first_frame: Optional[av.VideoFrame | av.AudioFrame] = None - self._audio_sample_rate: Optional[int] = None - self._audio_layout: Optional[str] = None + self._first_frame: av.VideoFrame | av.AudioFrame | None = None + self._audio_sample_rate: int | None = None + self._audio_layout: str | None = None self._dropped_timeout: bool = False - self._last_out_pts: Optional[int] = None - self._wallclock_start: Optional[float] = None - self._next_out_pts: Optional[int] = None - self._last_keyframe_time: Optional[float] = None + self._last_out_pts: int | None = None + self._wallclock_start: float | None = None + self._next_out_pts: int | None = None + self._last_keyframe_time: float | None = None self._stopped: bool = False self._audio_resampler: Any = None @@ -290,9 +291,9 @@ def __init__( config.mime_type, ) - self._thread: Optional[threading.Thread] = None - self._loop: Optional[asyncio.AbstractEventLoop] = None - self._segment_tasks: Set[asyncio.Task[None]] = set() + self._thread: threading.Thread | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._segment_tasks: set[asyncio.Task[None]] = set() self._segment_stream_lock = asyncio.Lock() self._start_lock = threading.Lock() self._track_wait_timeout_s = float(config.track_wait_timeout_s) @@ -306,10 +307,10 @@ def __init__( raise ValueError( "MediaPublishConfig.segment_post_idle_timeout_s must be > 0" ) - self._first_frame_arrived_at: Optional[float] = None + self._first_frame_arrived_at: float | None = None self._closed = False - self._error: Optional[BaseException] = None + self._error: BaseException | None = None self._started_at = time.time() self._stats: dict[str, int] = { "segments_started": 0, @@ -380,9 +381,9 @@ def __init__( self._next_state_index = 0 # Encoder state (owned by the encoder thread). - self._container: Optional[av.container.OutputContainer] = None + self._container: av.container.OutputContainer | None = None self._active_segment: Any = None - self._active_segment_started_at: Optional[float] = None + self._active_segment_started_at: float | None = None # Drain bytes for the rest of the current PyAV segment. self._segment_draining: bool = False @@ -393,7 +394,7 @@ def __init__( def tracks(self) -> tuple[MediaPublishTrack, ...]: return tuple(self._tracks) - def get_tracks(self, kind: Optional[str] = None) -> list[MediaPublishTrack]: + def get_tracks(self, kind: str | None = None) -> list[MediaPublishTrack]: if kind is None: return list(self._tracks) normalized = kind.strip().lower() @@ -599,7 +600,7 @@ def _run_encoder(self) -> None: for track in self._tracks: track._stream = None - def _next_encoder_item(self) -> Optional[tuple[MediaPublishTrack, object]]: + def _next_encoder_item(self) -> tuple[MediaPublishTrack, object] | None: active = [t for t in self._tracks if not t._stopped] if not active: return None @@ -890,8 +891,8 @@ def _flush_encoder(self) -> None: def _compute_pts( self, track: MediaPublishTrack, - pts: Optional[int], - time_base: Optional[Fraction], + pts: int | None, + time_base: Fraction | None, ) -> tuple[float, int]: if pts is not None and time_base is not None: tb = _fraction_from_time_base(time_base) @@ -979,11 +980,11 @@ async def _stream_pipe_to_trickle(self, read_file: BinaryIO) -> None: # Timeout to rotate trickle segments if no bytes from PyAV. idle_timeout_s = self._segment_post_idle_timeout_s - segment_seq: Optional[int] = None + segment_seq: int | None = None # Keep one read in flight across idle timeouts so we eventually # receive late bytes, if any - pending_read: Optional[asyncio.Task[bytes]] = None + pending_read: asyncio.Task[bytes] | None = None # True after we've read payload bytes from this PyAV segment. pipe_past_keyframe = False @@ -1006,7 +1007,7 @@ async def _stream_pipe_to_trickle(self, read_file: BinaryIO) -> None: asyncio.shield(pending_read), timeout=idle_timeout_s, ) - except asyncio.TimeoutError: + except TimeoutError: # NB: This intentionally keeps trickle rolling # accommodate long stalls / inactivity from the # PyAV end, eg during model loading @@ -1181,7 +1182,7 @@ def __init__( self._stats = stats self._debt_skip = debt_skip self._time_debt_s = 0.0 - self._last_encoded_media_time_s: Optional[float] = None + self._last_encoded_media_time_s: float | None = None self._stop_after_current = False self._stop_enqueued = False # Source-PTS media-time watermarks for queue-span telemetry. These are @@ -1189,9 +1190,9 @@ def __init__( # are not locked per op. They are only meaningful for frames that # expose pts + time_base; STOP sentinels and pts-less frames are # skipped. - self._last_put_media_time_s: Optional[float] = None - self._first_get_media_time_s: Optional[float] = None - self._last_get_media_time_s: Optional[float] = None + self._last_put_media_time_s: float | None = None + self._first_get_media_time_s: float | None = None + self._last_get_media_time_s: float | None = None def resize(self, maxsize: int) -> None: if not isinstance(maxsize, int) or isinstance(maxsize, bool): @@ -1235,10 +1236,10 @@ def put(self, item: object) -> None: if item is not _STOP: self._stats["frames_dropped_overflow"] += 1 - def get_nowait(self) -> Optional[object]: + def get_nowait(self) -> object | None: return self.get(timeout=0.0) - def get(self, timeout: Optional[float] = None) -> Optional[object]: + def get(self, timeout: float | None = None) -> object | None: if self._stop_after_current: self._stop_after_current = False return _STOP @@ -1357,7 +1358,7 @@ def _accept_candidate(self, candidate: object) -> bool: return media_advance_s >= self._time_debt_s @staticmethod - def _frame_media_time_s(frame: object) -> Optional[float]: + def _frame_media_time_s(frame: object) -> float | None: pts = getattr(frame, "pts", None) time_base = getattr(frame, "time_base", None) if pts is None or time_base is None: diff --git a/src/livepeer_gateway/remote_signer.py b/src/livepeer_gateway/remote_signer.py index 6196aea..81f6ab1 100644 --- a/src/livepeer_gateway/remote_signer.py +++ b/src/livepeer_gateway/remote_signer.py @@ -3,7 +3,7 @@ import asyncio import logging from dataclasses import dataclass, replace -from typing import Any, Optional +from typing import Any from .async_cache import async_lru_cache from .errors import ( @@ -21,7 +21,7 @@ @dataclass(frozen=True) class GetPaymentResponse: payment: str - seg_creds: Optional[str] = None + seg_creds: str | None = None @dataclass(frozen=True) @@ -48,15 +48,15 @@ class SignerMaterial: class RemoteSignerError(LivepeerGatewayError): signer_url: str message: str - cause: Optional[BaseException] = None + cause: BaseException | None = None def __str__(self) -> str: return f"Remote signer error: {self.message} (url={self.signer_url})" def _freeze_headers( - headers: Optional[dict[str, str]], -) -> Optional[frozenset[tuple[str, str]]]: + headers: dict[str, str] | None, +) -> frozenset[tuple[str, str]] | None: """Convert a headers dict to a hashable cache key.""" if headers is None: return None diff --git a/src/livepeer_gateway/segment_reader.py b/src/livepeer_gateway/segment_reader.py index 7250505..91c9ea0 100644 --- a/src/livepeer_gateway/segment_reader.py +++ b/src/livepeer_gateway/segment_reader.py @@ -2,7 +2,7 @@ import asyncio from dataclasses import dataclass -from typing import Optional, Protocol +from typing import Protocol import aiohttp @@ -51,12 +51,12 @@ class _SegmentBuffer: """ source: AsyncByteSource producer_read_size: int = 32 * 1024 # size of each source.read request - max_bytes: Optional[int] = None # safety bound on TOTAL buffered bytes + max_bytes: int | None = None # safety bound on TOTAL buffered bytes def __post_init__(self) -> None: self._buf = bytearray() self._eof = False - self._error: Optional[BaseException] = None + self._error: BaseException | None = None self._stats: dict[str, int] = { "chunks_read": 0, "bytes_read": 0, @@ -111,7 +111,7 @@ async def _produce_chunk(self) -> None: self._cond.notify_all() - async def _read_for(self, cursor: int, n: int) -> Optional[bytes]: + async def _read_for(self, cursor: int, n: int) -> bytes | None: """ Return up to n bytes starting at cursor. Returns None on EOF/closed. @@ -162,7 +162,7 @@ def __init__(self, writer: _SegmentBuffer): self._w = writer self._pos = 0 - async def read(self, chunk_size: int = 32 * 1024) -> Optional[bytes]: + async def read(self, chunk_size: int = 32 * 1024) -> bytes | None: out = await self._w._read_for(self._pos, chunk_size) if out is None: return None @@ -181,7 +181,7 @@ def __init__( self, response: aiohttp.ClientResponse, *, - max_bytes: Optional[int] = 10 * 1024 * 1024, + max_bytes: int | None = 10 * 1024 * 1024, producer_read_size: int = 32 * 1024, ): self.response = response @@ -208,7 +208,7 @@ def eos(self) -> bool: # Header-based end-of-segment marker (your existing behavior). return self.response.headers.get("Lp-Trickle-Closed") is not None - def headers(self) -> "aiohttp.typedefs.LooseHeaders": + def headers(self) -> aiohttp.typedefs.LooseHeaders: return self.response.headers async def close(self) -> None: diff --git a/src/livepeer_gateway/token.py b/src/livepeer_gateway/token.py index 11f354c..ce7af29 100644 --- a/src/livepeer_gateway/token.py +++ b/src/livepeer_gateway/token.py @@ -3,7 +3,7 @@ import base64 import binascii import json -from typing import Any, Optional +from typing import Any from .errors import LivepeerGatewayError @@ -43,7 +43,7 @@ def parse_token(token: str) -> dict[str, Any]: if orchestrators is not None and not isinstance(orchestrators, list): raise LivepeerGatewayError("Invalid token: orchestrators must be an array of strings") - normalized_orchestrators: Optional[list[str]] = None + normalized_orchestrators: list[str] | None = None if isinstance(orchestrators, list): normalized_orchestrators = [] for item in orchestrators: diff --git a/src/livepeer_gateway/trickle_publisher.py b/src/livepeer_gateway/trickle_publisher.py index 7b64ee7..2ecd4c2 100644 --- a/src/livepeer_gateway/trickle_publisher.py +++ b/src/livepeer_gateway/trickle_publisher.py @@ -4,7 +4,7 @@ from dataclasses import dataclass import logging import time -from typing import Optional, AsyncIterator, Callable +from collections.abc import AsyncIterator, Callable import aiohttp @@ -63,8 +63,8 @@ def __init__( message: str, *, seq: int, - url: Optional[str] = None, - status: Optional[int] = None, + url: str | None = None, + status: int | None = None, ) -> None: super().__init__(message) self.seq = seq @@ -80,7 +80,7 @@ def __init__( message: str, *, consecutive_failures: int, - url: Optional[str] = None, + url: str | None = None, ) -> None: super().__init__(message) self.consecutive_failures = consecutive_failures @@ -116,19 +116,19 @@ def __init__( self.seq = int(start_seq) # Lazily initialized async runtime bits (safe to construct in sync code). - self._lock: Optional[asyncio.Lock] = None - self._session: Optional[aiohttp.ClientSession] = None + self._lock: asyncio.Lock | None = None + self._session: aiohttp.ClientSession | None = None self._closing = False self._closed = False # Preconnected writer state for the next segment. - self._next_state: Optional[_SegmentPostState] = None - self._preconnect_task_handle: Optional[asyncio.Task[None]] = None + self._next_state: _SegmentPostState | None = None + self._preconnect_task_handle: asyncio.Task[None] | None = None self._post_tasks: set[asyncio.Task[None]] = set() # Terminal failure for the whole publisher. Once set, no new segments # should be opened or written. - self._terminal_error: Optional[TricklePublisherTerminalError] = None + self._terminal_error: TricklePublisherTerminalError | None = None self._consecutive_failures: int = 0 self._started_at = time.time() self._stats: dict[str, int] = { @@ -147,7 +147,7 @@ def __init__( "empty_segments_completed": 0, } - async def __aenter__(self) -> "TricklePublisher": + async def __aenter__(self) -> TricklePublisher: return self async def __aexit__(self, exc_type, exc_value, traceback) -> None: @@ -201,9 +201,9 @@ async def _run_post(self, url: str, seg_state: _SegmentPostState) -> None: assert self._session is not None seq = seg_state.seq - final_exc: Optional[TrickleSegmentWriteError] = None - final_status: Optional[int] = None - final_body: Optional[str] = None + final_exc: TrickleSegmentWriteError | None = None + final_status: int | None = None + final_body: str | None = None for attempt in range(2): self._stats["post_attempts"] += 1 @@ -359,7 +359,7 @@ async def _resolve_next_seq(self) -> int: _LOG.warning("Trickle /next request failed", exc_info=True) return -1 - async def next(self) -> "SegmentWriter": + async def next(self) -> SegmentWriter: # Fail fast via the publisher error hierarchy, not a generic RuntimeError. if self._closing or self._closed: raise TricklePublisherTerminalError( @@ -545,9 +545,9 @@ class _SegmentPostState: def __init__(self, seq: int, *, send_reset: bool = False) -> None: self.seq = seq - self.queue: asyncio.Queue[Optional[bytes]] = asyncio.Queue(maxsize=1) + self.queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1) # Failure for this one segment only, not necessarily terminal - self.error: Optional[TrickleSegmentWriteError] = None + self.error: TrickleSegmentWriteError | None = None self.data_consumed: bool = False self.send_reset: bool = send_reset @@ -558,9 +558,9 @@ def __init__( self, seg_state: _SegmentPostState, *, - error_getter: Optional[Callable[[], Optional[TricklePublisherTerminalError]]] = None, - on_write_bytes: Optional[Callable[[int], None]] = None, - on_write_timeout: Optional[Callable[[], None]] = None, + error_getter: Callable[[], TricklePublisherTerminalError | None] | None = None, + on_write_bytes: Callable[[int], None] | None = None, + on_write_timeout: Callable[[], None] | None = None, ): self._seg_state = seg_state self.queue = seg_state.queue @@ -583,7 +583,7 @@ async def write(self, data: bytes) -> None: await asyncio.wait_for(self.queue.put(data), timeout=_SEGMENT_QUEUE_PUT_TIMEOUT_S) if self._on_write_bytes is not None: self._on_write_bytes(len(data)) - except asyncio.TimeoutError as e: + except TimeoutError as e: if self._on_write_timeout is not None: self._on_write_timeout() if self._error_getter is not None: @@ -605,7 +605,7 @@ async def close(self) -> None: except BaseException: _LOG.warning("Trickle segment close suppressed seq=%s", self._seq, exc_info=True) - async def __aenter__(self) -> "SegmentWriter": + async def __aenter__(self) -> SegmentWriter: return self async def __aexit__(self, exc_type, exc_value, traceback) -> None: diff --git a/src/livepeer_gateway/trickle_subscriber.py b/src/livepeer_gateway/trickle_subscriber.py index a8add94..f56f7f4 100644 --- a/src/livepeer_gateway/trickle_subscriber.py +++ b/src/livepeer_gateway/trickle_subscriber.py @@ -4,7 +4,6 @@ from dataclasses import dataclass import logging import time -from typing import Optional import aiohttp @@ -61,7 +60,7 @@ def __init__( start_seq: int = -2, max_retries: int = 5, connection_close: bool = False, - max_bytes: Optional[int] = None, + max_bytes: int | None = None, ): if max_bytes is not None and max_bytes <= 0: raise ValueError("max_bytes must be > 0") @@ -71,13 +70,13 @@ def __init__( self._connection_close = connection_close self._max_bytes = max_bytes - self._pending_get: Optional[aiohttp.ClientResponse] = None - self._lock: Optional[asyncio.Lock] = None - self._session: Optional[aiohttp.ClientSession] = None + self._pending_get: aiohttp.ClientResponse | None = None + self._lock: asyncio.Lock | None = None + self._session: aiohttp.ClientSession | None = None self._errored = False self._closing = False self._closed = False - self._prefetch_task: Optional[asyncio.Task[None]] = None + self._prefetch_task: asyncio.Task[None] | None = None self._started_at = time.time() self._stats: dict[str, int] = { "get_attempts": 0, @@ -91,7 +90,7 @@ def __init__( "latest_seq": start_seq, } - async def __aenter__(self) -> "TrickleSubscriber": + async def __aenter__(self) -> TrickleSubscriber: return self async def __aexit__(self, exc_type, exc_value, traceback) -> None: @@ -110,7 +109,7 @@ def _segment_url(self, seq: int) -> str: return f"{self.base_url}/{seq}" @staticmethod - def _latest_seq(headers: "aiohttp.typedefs.LooseHeaders", current_seq: int) -> int: + def _latest_seq(headers: aiohttp.typedefs.LooseHeaders, current_seq: int) -> int: latest_header = headers.get("Lp-Trickle-Latest") if latest_header is None: return current_seq @@ -119,7 +118,7 @@ def _latest_seq(headers: "aiohttp.typedefs.LooseHeaders", current_seq: int) -> i except ValueError: return current_seq - async def _preconnect(self) -> Optional[aiohttp.ClientResponse]: + async def _preconnect(self) -> aiohttp.ClientResponse | None: """ Preconnect to the server by making a GET request to fetch the next segment. @@ -203,7 +202,7 @@ async def _preconnect(self) -> Optional[aiohttp.ClientResponse]: self._errored = True return None - async def next(self) -> Optional["SegmentReader"]: + async def next(self) -> SegmentReader | None: """Retrieve data from the current segment and set up the next segment concurrently.""" if self._closing or self._closed: return None From 2017a80c740af5fcb0fef4ee84659af9b07fb90c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:37:14 -0700 Subject: [PATCH 5/7] Bump the uv group across 1 directory with 2 updates (#85) Updates `aiohttp` from 3.13.3 to 3.14.4 Updates `idna` from 3.11 to 3.15 --- uv.lock | 393 ++++++++++++++++++++++++++++++++++---------------------- 1 file changed, 236 insertions(+), 157 deletions(-) diff --git a/uv.lock b/uv.lock index 4253fbc..04664e8 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12" [[package]] @@ -13,7 +13,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.14.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -22,78 +22,135 @@ dependencies = [ { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +sdist = { url = "https://files.pythonhosted.org/packages/93/2f/6a91adaa2dc26877d6ed2f54c0370c8910f019db7d77c5c6a194611e93ea/aiohttp-3.14.4.tar.gz", hash = "sha256:831fc5bd39ec2517851e348f613ddb5447a47cf4b71cb09845af7ad7ed45d8f9", size = 8077839, upload-time = "2026-10-05T17:43:48.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, - { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, - { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, - { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, - { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, - { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, - { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, - { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, - { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, - { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, - { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, - { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, - { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, - { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, - { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, - { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, - { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, - { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, + { url = "https://files.pythonhosted.org/packages/22/02/db5935d45347fa8ede56b777930740fb0e72bbb4473ddb6ff8541f6f2afa/aiohttp-3.14.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:55b0b335982b0db7117dff7f6a8e3aad43c4105f9e392ca04f9fc8d958a95cce", size = 817689, upload-time = "2026-10-05T17:38:50.69Z" }, + { url = "https://files.pythonhosted.org/packages/e6/56/f00aa46127e6342f019d567853eeeb932e04fb9b9424b5fb981ad68f4178/aiohttp-3.14.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d0d8e435582b0d53009dac99e26a3d2be932ed7c9dbfcfc70f3fce1cf943bd5", size = 549599, upload-time = "2026-10-05T17:38:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bc/825c09b09962253a2103e4a88c04374ee939c0c8ea31c270daa0308efb07/aiohttp-3.14.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bf734016932d68e1324cfb638cac7a0866a83933a33badf15e836a7e24efb8c0", size = 548132, upload-time = "2026-10-05T17:38:54.629Z" }, + { url = "https://files.pythonhosted.org/packages/40/80/8a1bc8036540fe7cb38219cc5c0aa67d32e9068165725e8199ea8200b08b/aiohttp-3.14.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:322e3d741b3133bcbb981a41d4f9b8ac3545c18de49c662c2b1f4e1c770b98ad", size = 1898485, upload-time = "2026-10-05T17:38:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fe/65b464d8520ace0d65b4ca69bc9abe175f842c8ce8d1522d363d9669903f/aiohttp-3.14.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15a3bb5f90a4e515071f3617a45dfe26560beb834a6b6e108cdf4bc9e9719ec3", size = 1875334, upload-time = "2026-10-05T17:38:58.536Z" }, + { url = "https://files.pythonhosted.org/packages/be/51/9c583be502720e927560a8414f4dfb7c7de41945e1a30ccf7003b93c6f85/aiohttp-3.14.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6c044e52df1a466af82cad3518c92ea80cc4ddcfea86966ff083bb34bab29bf4", size = 1937772, upload-time = "2026-10-05T17:39:00.376Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3d/b56512df7ea9cbb5f59099a6b375088dd578356a5a3fb27198ab82e19832/aiohttp-3.14.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d613d7d51bf06fe9a5e3aab86ce06d835bb0ceb1a31ea9e8766df4a403ae62a6", size = 2066220, upload-time = "2026-10-05T17:39:02.54Z" }, + { url = "https://files.pythonhosted.org/packages/11/6c/a7a42701d9ea319140a3d44a24ec72a00b0b46d1b899b26d8a705e65bdd8/aiohttp-3.14.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5e93ab6e6330dce40f5b43996f1fc5f2543296d263a5e4b59ff09e73870b7d8", size = 1922895, upload-time = "2026-10-05T17:39:04.638Z" }, + { url = "https://files.pythonhosted.org/packages/77/04/f8337bd824ca3fe3968d34dc5037ace1c4872551c4621f8266239311d301/aiohttp-3.14.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:20134dc8e68c67e7478124d516449428684c0545b2d3faa575691c8bcbd56678", size = 1712589, upload-time = "2026-10-05T17:39:06.671Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a6/d20e2c940b592d7c4a21690889dbff64fe0ca3facd9190696368694e1488/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f0daef5ef012369dddc391c61f604111424aa9a96313a69fb899018b09d1b01e", size = 1862251, upload-time = "2026-10-05T17:39:08.523Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e4/d50edd5ae5d024b380a8e892e333840bb22967a9eb9c8492108a403f678a/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:19290a108de0718b73bc69b86e788975c6a59855c671ebe2a98a07a6f968328f", size = 1895155, upload-time = "2026-10-05T17:39:10.305Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d3/f36f2931f807ccf8437ec15e8f8bfaa4748d4792639401cc767a866abaa0/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1494877625baeffab669f4a25a09ec4eea721b49e35363081a2a8e4231fe141f", size = 1906623, upload-time = "2026-10-05T17:39:12.411Z" }, + { url = "https://files.pythonhosted.org/packages/5d/0a/94821b711493142bf5e11c27b0d7502776778f7792c77ecc6647357d20c1/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a779ce4bf099ce4ad2cdfed62f1ca536d435f53237c54ff40172fddada24d1b5", size = 1698932, upload-time = "2026-10-05T17:39:14.446Z" }, + { url = "https://files.pythonhosted.org/packages/d9/76/3d5a0d28362f0053c1c457024749911932481e8fd105fddf8305b1f93c81/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0b4cec9fca876e2d4f4c6cb32b177390132d04618867689f43fefabf3f5c3980", size = 1934528, upload-time = "2026-10-05T17:39:16.354Z" }, + { url = "https://files.pythonhosted.org/packages/a7/51/9784a21dc05d2e0f6acaf864f16db53dab0fbd7b0753cef168601eacb012/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:217707313e23aedde9bea67cf8365c7b138b549ae179121ef3f476cf67e3173a", size = 1901984, upload-time = "2026-10-05T17:39:18.299Z" }, + { url = "https://files.pythonhosted.org/packages/44/4f/f98c88703e6d620243aa08473a946ca4e3756b6288690e38f0d9f9c1d007/aiohttp-3.14.4-cp312-cp312-win32.whl", hash = "sha256:18a9fb9a3f6e6e63f4d27f9adeef313a2d3c69a84d31170a844623824e722534", size = 480278, upload-time = "2026-10-05T17:39:20.206Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bc/8555013f84b53e4fdde423131b641af50e822c6d7d9118b24c52b31f6900/aiohttp-3.14.4-cp312-cp312-win_amd64.whl", hash = "sha256:69dcb02d33dfe415d5ee342cc63b7d320efcaf4b761701c7bd7bfb91c715481a", size = 506317, upload-time = "2026-10-05T17:39:22.706Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/ca5bad7bfdaa7ddca053a044a1c7c0d4f3ad9d1f12f96dcab10bfb9c50e7/aiohttp-3.14.4-cp312-cp312-win_arm64.whl", hash = "sha256:d36b0263e7c2fbf1750b9f35d4e7e48b0442d8c45e4a89e3e74168821fb014bb", size = 487683, upload-time = "2026-10-05T17:39:24.482Z" }, + { url = "https://files.pythonhosted.org/packages/23/ec/7fee140da700ec3f5aa5e0fd2319f67be5e86de197e2384d27e5e6209e0e/aiohttp-3.14.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:264b5a7568306590c44ae4a0237ad1715e35d447ea44ebf324e4c8fd9f78f67a", size = 539114, upload-time = "2026-10-05T17:39:26.322Z" }, + { url = "https://files.pythonhosted.org/packages/4e/04/930c01ce9e186e787046433d7dc5b45b188885f8406c14b58435b4431974/aiohttp-3.14.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:17e5d7c7775d8dd8e894c7ef6ebfa26509de36868a9a6c7c4fcfaf6a1bf43d60", size = 553514, upload-time = "2026-10-05T17:39:28.104Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d7/983e3cea81c07e95f35d34da4d1e182b5c05489c49b7560b198a83f0da79/aiohttp-3.14.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e91dc8fe9cbd052d16f7f1267e285cf8bbac4d2194bd5741f05ef201c77ed09e", size = 521381, upload-time = "2026-10-05T17:39:30.419Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/80baed11659093883f51245755105557d3253e45bd0b693d70df0059d68b/aiohttp-3.14.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8df6157d31703972aec9e3b93963f039ff06318301e30b007c3e941ff8a7ac42", size = 528347, upload-time = "2026-10-05T17:39:32.344Z" }, + { url = "https://files.pythonhosted.org/packages/c8/18/bfda255c311096c08dab26dc65f3d219f090df37dc5f5f6345f8c3aefc1f/aiohttp-3.14.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:220912b351549dc736c59d104dc443fa615753102bb778a4e98f86e4d94bff6a", size = 539914, upload-time = "2026-10-05T17:39:34.265Z" }, + { url = "https://files.pythonhosted.org/packages/51/fc/3b6ca6117c0c42481d719411fff7b5d3970ebd906543d3dc1ba40fe2462f/aiohttp-3.14.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2efa42b39bb3f524d5eca3760639b584afdd1143a6b047fda29910ffb75e4993", size = 813997, upload-time = "2026-10-05T17:39:36.061Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f6/86e3e02a9712aadc1ae0a1076bb2d1adf3d9addfe5542a28190c8f9b6d9b/aiohttp-3.14.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e7356059c8262d2c1fd95242e2b7b70fbc6eee3135bf470049cbec3e968b5b0", size = 548058, upload-time = "2026-10-05T17:39:38.109Z" }, + { url = "https://files.pythonhosted.org/packages/85/7e/6b9c2e271419d40ca76589613b3c7ab185fd3050a407bd2565b6aa46f695/aiohttp-3.14.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fac247d0cc956d732df1211ac2d8a9999ec8dc1a92a90401de9d52e4ead546f7", size = 545933, upload-time = "2026-10-05T17:39:40.193Z" }, + { url = "https://files.pythonhosted.org/packages/14/fc/33d6e24a5f8ac32423230088c415e2a1c6efee23d902b62f20506631e40b/aiohttp-3.14.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2eb0f8b03b4f2bd154ed8cf7c1a119a1ad04808c258bf58f90138c83b8c6d5", size = 1888265, upload-time = "2026-10-05T17:39:42.182Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e8/2eca1d468d83a514621d1280f9992d08649191db2028c9236b4edfa7c723/aiohttp-3.14.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbe827d27e0e369fd7bb88ceeaa5b46ea006e73e015b9dd77e3723a53162293", size = 1872300, upload-time = "2026-10-05T17:39:44.195Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/5872983de71b0214acdfb86822f5cca295b39be4832ec589fa224aee0f6c/aiohttp-3.14.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70387146d03af9f047c629b71aa27d9594b3b31ec4c9c91e1a73d0d8bf38e0e", size = 1926213, upload-time = "2026-10-05T17:39:46.199Z" }, + { url = "https://files.pythonhosted.org/packages/ee/65/ebfefa11a546c33c717af63c67868408ecf0b33b23a9d75669af95485407/aiohttp-3.14.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d0934926fc65744e2fdd44ce68b2d79d5ee608a1e23f0596b35dd696519bb7c", size = 2056609, upload-time = "2026-10-05T17:39:48.221Z" }, + { url = "https://files.pythonhosted.org/packages/69/c0/70667e82ec041f4122a17afce711bbcf94a71bec5888b69a49aed8817be2/aiohttp-3.14.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff4b366510c733974adb7a4102c094e4a6308e6f369315a4cf14d2d0a619cf82", size = 1914740, upload-time = "2026-10-05T17:39:50.304Z" }, + { url = "https://files.pythonhosted.org/packages/bf/86/559e9dfea676206b20737d7ccb5b3716bff5e58d2a8d53682e5301724cd1/aiohttp-3.14.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:85fac7c99e0ac3dcbd6ae1c33774e0603bfc732ddb967a6fce4a731103c1888e", size = 1703390, upload-time = "2026-10-05T17:39:52.385Z" }, + { url = "https://files.pythonhosted.org/packages/fc/91/c98897d4cf23e120b2f3980042835cb34c86bbe3c653f3eaacf4bf986349/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef1e9c3b4a2300023a5dc865889ce532cfaf6798ba4ef78fbbefe738a9d23efa", size = 1852642, upload-time = "2026-10-05T17:39:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/14/13/0258f352cb2c236f358a5b57d138e15fa2566e17accc0758d968dbf2ceb6/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:97e65916588be0f952b3307aaa8460c0771fa98dbea2dd27dabc9f5d84d53c3b", size = 1890616, upload-time = "2026-10-05T17:39:56.466Z" }, + { url = "https://files.pythonhosted.org/packages/64/3c/f43e294ce1a06d1234a97bd862c1aa31acfa1a0eea3786c22fd0dc1ea731/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:840775cb39a9424f9edf142133a1192263ac5bd79117ad2a16602cef6a0c1e9d", size = 1896638, upload-time = "2026-10-05T17:39:58.585Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f7/ce1e3a85b73d05037d729d3d32da7d6959b9e433435e9abd46c2fb38410f/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:54f9256430ca040d58affbd551513de13a5cff7d065204fc52b9a1b0af09a049", size = 1693003, upload-time = "2026-10-05T17:40:00.698Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/45eb900d6a9ee046728f5589f3d07f5d78f049b5fb84f48881fa4174d5df/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:6112d5931bf12c776188cab81b82bbb17d86e2e1381f2ade6edc43e6b240f0cb", size = 1927730, upload-time = "2026-10-05T17:40:02.943Z" }, + { url = "https://files.pythonhosted.org/packages/b2/43/a83ff79dc8375953a1f00b808a21c82590f4e68304fecccdcc42a25fb7d8/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:facc6df39047c4732cbd885bb62e40df20885fcd709fc399e11f81b45ce0fff9", size = 1891177, upload-time = "2026-10-05T17:40:04.989Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ba/fbdc41608d4e2fb6084d960a41b392fa82beb5b126c2466de549b5c67fc8/aiohttp-3.14.4-cp313-cp313-win32.whl", hash = "sha256:7968634fa3a967a2bc0b9aeba1d5a1955501dcd7d0de44b96f7ebfb662797735", size = 479943, upload-time = "2026-10-05T17:40:07.402Z" }, + { url = "https://files.pythonhosted.org/packages/35/51/d75629750e705832fd5cd52516a82943ed58b6e09c30e14830f5d87452fa/aiohttp-3.14.4-cp313-cp313-win_amd64.whl", hash = "sha256:c0a894b0265d139cd4c2c8bd4643822cc289a4a5331e21e810881bd000ffb2dc", size = 506209, upload-time = "2026-10-05T17:40:09.49Z" }, + { url = "https://files.pythonhosted.org/packages/02/64/66452d2ffcc13e5e5ee4ba4a968656fa4a98c29ed5ff0205d4fde02b2943/aiohttp-3.14.4-cp313-cp313-win_arm64.whl", hash = "sha256:da16c3037178e72589d91de59536d271dffc92c2be2b47c38a45538cba54fd29", size = 487163, upload-time = "2026-10-05T17:40:11.526Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fd/b653b1f969e2e949fe4726af7a8043e8ef117f202bed7757ac8564e077a1/aiohttp-3.14.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:749bfc43bb1df71bbe6ed215044d5412d786698fc5771d2037436ca49622f3c9", size = 540738, upload-time = "2026-10-05T17:40:14.109Z" }, + { url = "https://files.pythonhosted.org/packages/90/b5/eea49a0bb697deffac5165d707ec53a24748d6720b3af77a544107b49573/aiohttp-3.14.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:c113e4b489d711f606ca278cd8dc89179746244ea841aa0638b1b134600afad0", size = 554494, upload-time = "2026-10-05T17:40:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/11/ea/cf8f4efbbfa8572d9965cd43b3c6c84c581aa006772e5ce3ab4020027e9b/aiohttp-3.14.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:50969522bc7022026a2c7968e67da5c789f6b716b988edac55dc630390efd5df", size = 522653, upload-time = "2026-10-05T17:40:18.879Z" }, + { url = "https://files.pythonhosted.org/packages/a3/24/9f6721f0b52323789cb1041ffc3afa729462d9925607e733a065b6fc5e17/aiohttp-3.14.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:cbf92cd7a1c800cee244558dd5630abb45c1c1093ef7b0d546a99f526ff7a9eb", size = 529697, upload-time = "2026-10-05T17:40:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a4/399debc3c2ca0ab88a08e3082b4c4f62d0f66978af60243c63f2345ef337/aiohttp-3.14.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:fc76be08ff407fba717a769aff04ca1b4bf9ecfdcb5caadef84a563fdbbd146d", size = 540526, upload-time = "2026-10-05T17:40:23.548Z" }, + { url = "https://files.pythonhosted.org/packages/a2/97/90379813b18b795bccdeb3a5a85e9b93ac2c69f9c2a5d0faba59da3ad6d1/aiohttp-3.14.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9b416f995b68a921069e6ec61fa07f2ac3ff033d92e97a69f673af89f5bb954d", size = 818866, upload-time = "2026-10-05T17:40:25.842Z" }, + { url = "https://files.pythonhosted.org/packages/78/70/824fd0dac819652f1df895b3f621a75bf0a3050c30e38d8d2316e34ab821/aiohttp-3.14.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2c9585101cfca10e74a07cc0ef2bdc6d90616e6750926b20456f429d0608f740", size = 549666, upload-time = "2026-10-05T17:40:28.523Z" }, + { url = "https://files.pythonhosted.org/packages/8c/45/eafb2dd200dd2b770ed25a2dc5731c4ba5d58ab3fa8e2e82494bb946808e/aiohttp-3.14.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3c4dcf611b095e7983421f49d01ffbed7ed574891533d606ea115fc23e7c4e90", size = 548961, upload-time = "2026-10-05T17:40:30.613Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/6c733a1c71300293471ac7ef1dffd12b5b4dc9a43c53461224b2b8a7f667/aiohttp-3.14.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8f61a9b5fa56c58e3441f9dc64137f786232d40fb89127190d0e0f84e1e5865", size = 1883165, upload-time = "2026-10-05T17:40:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/37/34/885ddbd351679a31141b25e0d1a5e4f67738806e564c858b399709b61239/aiohttp-3.14.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d43e538dfc85b705fd832620d050970ef5613b3869a7169b44a554b9dab9eee9", size = 1844119, upload-time = "2026-10-05T17:40:35.266Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ef/0a64dac53c11d569851efb3e7eddb43551df72b2c172ebb34cccc93bcd3f/aiohttp-3.14.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4135c4bb4b7682a7633fadc7170ea969d843f83aa7224e70111a20675105c985", size = 1930222, upload-time = "2026-10-05T17:40:37.527Z" }, + { url = "https://files.pythonhosted.org/packages/69/5f/774792ed8f973e0d234fcb06b0f0bc9f29e9a0a0dd847f6418c2904be7d9/aiohttp-3.14.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9055324f156a94fdf6dc9a825ae60f9a2947b88f591ea81ee80826633de62ae7", size = 2038842, upload-time = "2026-10-05T17:40:39.815Z" }, + { url = "https://files.pythonhosted.org/packages/cb/05/e00f35581c97656d9e864edaa03292382c620dd1d9c836a4f73a2279acc4/aiohttp-3.14.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb22c8e4b60385a9b414c70c6e8b2d0f4b23ff7fc4bb572ceb58297b228169f1", size = 1887845, upload-time = "2026-10-05T17:40:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2e/07e3eff5b4c1b082a2eaf0ffb508b409b21ea1ddc9fdc57f3eabb51469a1/aiohttp-3.14.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3b6945559a260112742ed58cde2165488d2fc51aeea34afbaa99d9371b43e1b5", size = 1702533, upload-time = "2026-10-05T17:40:44.569Z" }, + { url = "https://files.pythonhosted.org/packages/75/44/816844b7f876e6f8fee4ecb088269fcd92241da712e4814a4d624eda13a9/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f4078a90b174f15efb2c8725e8720c40dbd3b2cf6350dbc085c964a938d9de7", size = 1849058, upload-time = "2026-10-05T17:40:47.818Z" }, + { url = "https://files.pythonhosted.org/packages/ae/bd/163242e12464a1f3e58a77e28beca949652ddce84abdd5500a91c70a1e0d/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f433c5654e32f72de3ef074d715eb5c7604eb6d28a5c2f3c9e5056f20fe6b306", size = 1854600, upload-time = "2026-10-05T17:40:50.401Z" }, + { url = "https://files.pythonhosted.org/packages/f2/59/5c1ae708a89524eddfd9c727b87cae26b5c50c3c76683b910ac06151ead0/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:871035fe79c636b6bc126ed78df37d72b830dfc751612cb4b1040360edf2719f", size = 1896565, upload-time = "2026-10-05T17:40:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/37/45/95300a7de69241fe47d3f838f3fb582c712782e172d447ff650a013ea673/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2617132524f66338f6c1d218cd16081576aefc40df4f2bcba338a99fbf67fec1", size = 1693092, upload-time = "2026-10-05T17:40:54.862Z" }, + { url = "https://files.pythonhosted.org/packages/ff/33/01a8e61d48d0094121092c74125df14c0f8f10ee3149bfe68b6f869409a9/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d8bc326133272deee8ef337581550450e4bb735c0520be893b950a1f5baaf41a", size = 1917073, upload-time = "2026-10-05T17:40:57.656Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d6/1a4f24624cda6686d5b927e26d5d1ae3ebb692faf88e93017fab4b391fd0/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ec57223e261b7fa9979c12dbce009ca53439be76cac3af27f860d3e4b1d6f7a", size = 1872656, upload-time = "2026-10-05T17:41:00.123Z" }, + { url = "https://files.pythonhosted.org/packages/be/be/ed286fcfa2d22061ae021b568c790123505cd482df35e23023339ecaed96/aiohttp-3.14.4-cp314-cp314-win32.whl", hash = "sha256:7242043e71fc449a19a47d92f4e9fd1e0ae8d3f88488982381f422bf6d651218", size = 485461, upload-time = "2026-10-05T17:41:02.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/74/e040e6f65f4da305777f1adb3023d47401693489e4a337c983b52d2a0a6b/aiohttp-3.14.4-cp314-cp314-win_amd64.whl", hash = "sha256:019587c7b3a44917dd61bcb312fd5c034f341dc9851b83e7b41b65e08b68ca0f", size = 512147, upload-time = "2026-10-05T17:41:04.98Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5e/bddb33ae15fdd5a68a10a986587aa9916c418a712fdd110d3b3ae9b51407/aiohttp-3.14.4-cp314-cp314-win_arm64.whl", hash = "sha256:60dc71d38db0988f37e78f6ad4a010a2f6355cfac3fff5f130cf5ef2231f3f0d", size = 494685, upload-time = "2026-10-05T17:41:07.366Z" }, + { url = "https://files.pythonhosted.org/packages/22/2e/7b884014b40573f919fc3a528e5028bc37b249e31a93928ba61bdd25c960/aiohttp-3.14.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:674f53ab9d2794e2dd767619a3384bf1006f8d075e43c19d449a645f8ff641c2", size = 855696, upload-time = "2026-10-05T17:41:09.514Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e6/b6e2b4f580ab6ae39241bb9182aca60e6e1915cdad0148faaa04db3f366b/aiohttp-3.14.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6d243221f73e5ac546c2ed9255100423e4bc092b08b944f23498e8cf3096c26c", size = 567931, upload-time = "2026-10-05T17:41:11.685Z" }, + { url = "https://files.pythonhosted.org/packages/63/99/ae8e3a1a18adcbf636839d18b3cccc51007836ccdae5209381c5cf82d1ae/aiohttp-3.14.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0daa016eb74e7888ed5f9ceec6eea3158999c1c4a115ef5cbe88baa8d924f94c", size = 567421, upload-time = "2026-10-05T17:41:13.836Z" }, + { url = "https://files.pythonhosted.org/packages/63/38/c5d4bb58be72b967a4090d2deeb38571f8c992852936c51dbbbeae49db1c/aiohttp-3.14.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70c031a622aa20add05ebd234b90adaf6bf51c1c88cc305ec760721af83308c3", size = 2054173, upload-time = "2026-10-05T17:41:16.098Z" }, + { url = "https://files.pythonhosted.org/packages/eb/87/3c8a9c7670f56d478a75c5bb84b336fb61a67b908e36c30a4a96ed8c8e86/aiohttp-3.14.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7df363fa7c38945a896e12c69af4a1a56cbd4152791d9e41fd0bac396b72de38", size = 1918296, upload-time = "2026-10-05T17:41:18.478Z" }, + { url = "https://files.pythonhosted.org/packages/49/97/bd00e499c0a1505b44f5e23b9739034ea9be792ad42537f2894211ba1c07/aiohttp-3.14.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02a508ba5ad91c4d730523584c0adc65882274bba5e37c33bc2f475e37577256", size = 2027513, upload-time = "2026-10-05T17:41:20.874Z" }, + { url = "https://files.pythonhosted.org/packages/23/1d/a3666a7c224f86a881f18d673a8ffdc92fa4c35274fb4361822f8f0dc566/aiohttp-3.14.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0cb3edece6fe6a944eaf97d803b88f606da6bc1600b1894c2969e78ffdc1fc29", size = 2152686, upload-time = "2026-10-05T17:41:23.181Z" }, + { url = "https://files.pythonhosted.org/packages/46/7e/f4204b8c4959c46f6367c182afbbacef08a1b53874281912c52fbd80151c/aiohttp-3.14.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21ce1223b461bcc5fb389a7204001578f1c0b85c2da60a84d915ebb021294245", size = 2006431, upload-time = "2026-10-05T17:41:25.776Z" }, + { url = "https://files.pythonhosted.org/packages/33/43/091e69d42967cbea833ceb355c5457f8f70748fe08b88c9d91491b3fafb8/aiohttp-3.14.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35d5432f0dc1c8b71307e9112461b52f336da225e344283eec4779222c0a1dff", size = 1789101, upload-time = "2026-10-05T17:41:28.095Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/b57cbe533c01098c5d30471f057e7c3c19f281122f756acc9c716b636e2c/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6ec364493057c118d08292f7cd60865965c209167d8657b45643a26aef77495", size = 1974472, upload-time = "2026-10-05T17:41:30.499Z" }, + { url = "https://files.pythonhosted.org/packages/9f/44/79acce03611450e4ed8594f96f1cea40d70bc84fb4e918f94d7d9454a4af/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:70492983b7aed9e61bda82fe1ce80bcba9199610886443133783a288638923c0", size = 1956340, upload-time = "2026-10-05T17:41:33.009Z" }, + { url = "https://files.pythonhosted.org/packages/da/bb/36e6cbd6720e3377a02f8709aec6fe80de729ef41e78ba01f13d50363c73/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a77776673331577147e9d25cd7833a72e8891b47556336cff06ba1569e488b99", size = 1965771, upload-time = "2026-10-05T17:41:35.506Z" }, + { url = "https://files.pythonhosted.org/packages/69/0f/879735ac70ce5e6852916eed51c706a517b205150a8d384f9fa60933a3fc/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:73799000dd6fd5247094fd8e6d3e1eb871e41aab5cc35dff9d9f654cd230bc24", size = 1780297, upload-time = "2026-10-05T17:41:38.284Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/7c030ca5b6baf3b02eccd11afc3d58f0b35e407031d5cc78cae9c24921c0/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:27b370fb532e707e8c96d04fa0c96301fb7dba2d50aaca57f6c456cdfffec5bd", size = 2001374, upload-time = "2026-10-05T17:41:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/ddacbb8141d727034c51dd192d17749521d5b30b03f84aa034325396089e/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:884e0d1c707b6217fee3080b9b4029eee7551af788ee761010df19be158414b7", size = 1968531, upload-time = "2026-10-05T17:41:43.62Z" }, + { url = "https://files.pythonhosted.org/packages/3a/08/2214f823701eebc3e57db141faeae76df7bfd415f254cc7c1692258f228d/aiohttp-3.14.4-cp314-cp314t-win32.whl", hash = "sha256:f2e3d34edb151d3e27f93a8338e2da33e2c7261cd4c879ce5f5fe280cc190ea5", size = 504126, upload-time = "2026-10-05T17:41:46.321Z" }, + { url = "https://files.pythonhosted.org/packages/91/a1/90e9b6fd23310b4e82d0c0a37b5ecc2944053350473c57533c1da9647235/aiohttp-3.14.4-cp314-cp314t-win_amd64.whl", hash = "sha256:88cf889e51092537fe44adfbff96c2a22d6479d6f92b97b3d413fbfd0a4decf4", size = 531494, upload-time = "2026-10-05T17:41:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/5b501472c92b9b1dc53742591d6210dccb262962f4721299300d3a9f664b/aiohttp-3.14.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ca450c6d42fda7c0bfaf0e200f32f77c5f2c960a33be1e14d197c84588b7f5f6", size = 510544, upload-time = "2026-10-05T17:41:51.358Z" }, + { url = "https://files.pythonhosted.org/packages/0f/77/ef165a197850573ea07c8d77bf5f4854a1db12fdb20e1cf0b218abf025fc/aiohttp-3.14.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:3b10799df72f66c30935bc661cb89e5ac2918debfc575e350e226fbaa846cbb7", size = 540737, upload-time = "2026-10-05T17:41:53.761Z" }, + { url = "https://files.pythonhosted.org/packages/e6/aa/1663f64d1316b3be9d3f6fafa02b8d888070171a6544cba515e82f3af595/aiohttp-3.14.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:7ffd7f9dd39caccc6b2f68ec0339435ecad2b20f6ea2e2b3de91bbe464ac7137", size = 554600, upload-time = "2026-10-05T17:41:56.183Z" }, + { url = "https://files.pythonhosted.org/packages/98/f7/d4d535855123c1a774a798643e75fdfbac62c047b0c0729d6505d09df444/aiohttp-3.14.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:a0a7a227353f7693f73878ad14cd2623cafadd656ababcd20e2adfb7225172ea", size = 523184, upload-time = "2026-10-05T17:41:58.624Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5f/18a8d1cd2958a0b6164b047c18ccb192f2123bb70f1c0a145f6f01f858ad/aiohttp-3.14.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1c43b6af8e4708e8b3812a9ee4f79895207e12d555b7f6d70deb838e0011e12d", size = 530342, upload-time = "2026-10-05T17:42:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/d1dd2b1fcf8a3192a431375b287a8762ebfd22195ab5b201d3fac30e3a7a/aiohttp-3.14.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:97416b2b997e5e9b817e322b2f0a89b76c54cde22471f51ac17ac15e10901ba0", size = 540629, upload-time = "2026-10-05T17:42:03.324Z" }, + { url = "https://files.pythonhosted.org/packages/37/51/d1868f3459f618f6b68a6ef549d8b3f76aa616fa083298911fd3ae65d6b9/aiohttp-3.14.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:ee2a5aa31e507c17841d592364f2148393b17f83bc2766e9211e1eb31ea72a70", size = 819347, upload-time = "2026-10-05T17:42:05.713Z" }, + { url = "https://files.pythonhosted.org/packages/84/e6/53179b721b67e0bd20974d02eca97b5691559e8ce666ba23a65aecda6b7f/aiohttp-3.14.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:5d5d9843c34866e986dd24eef713452609e304550c471320ce7b54b3cfb1391e", size = 549825, upload-time = "2026-10-05T17:42:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/00/0f/d67ddf7a43d4899176954511b6c048328abec1a5df4901f811a3b408a4fc/aiohttp-3.14.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:efcbf93e31b52c21665b6ebc489eb6fdb0dfd86b592edc677b5cffdfa7b11677", size = 549315, upload-time = "2026-10-05T17:42:10.766Z" }, + { url = "https://files.pythonhosted.org/packages/9d/db/42f372c7f8e259505f239cb49db75f23548deaec56d22c68b4866f1c3fe2/aiohttp-3.14.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f1a79977794592dcf6485ed964ec2448db527a1bc0c72ec89663658dad3a739", size = 1892181, upload-time = "2026-10-05T17:42:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/23/0d/8b1900216302a94f69b41bf0786bb992a6e67d703f4698e335c2442db615/aiohttp-3.14.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:515e7ac6d27509dc3ba2c35fb88970b00b9e7b07ee7cc947c8733dcd54ef5def", size = 1883513, upload-time = "2026-10-05T17:42:15.704Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5e/15c014313e567fff931ec4a5f81683cc40251257791fbd607ec37c0dad80/aiohttp-3.14.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:185154ffa3b54765b5de96f464f41cfa476f8815a03854ae5e0fe4d48fb06e9c", size = 1933283, upload-time = "2026-10-05T17:42:18.241Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b3/bf7dc39a450218efb1d5c692bac7dc85ef56da282e889bee594f3267211e/aiohttp-3.14.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6aece59f77959628a22cd2ffe48b102cda9611d680e090f204385b3998a1190f", size = 2041054, upload-time = "2026-10-05T17:42:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/b6/03/ffba18d7aaedb5eafac3f41f0c26621ebb0a740bb0115ea2fae1911d902b/aiohttp-3.14.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7205aa6589f1ad1306c6994ff2e1ab5efabacf938f9ffd7682fb225c197b06a", size = 1895494, upload-time = "2026-10-05T17:42:23.518Z" }, + { url = "https://files.pythonhosted.org/packages/45/f4/68123365b86826eee82bdceb7fb4c429204cb3a104d6b7ae44bc6a543fcb/aiohttp-3.14.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f38c7b3ce60d55ac0f2a9fd2edc194789d043fefea15cfb7dcb40b7d5f561146", size = 1714098, upload-time = "2026-10-05T17:42:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/b13c0b214afc571782e8667f3b9e0bbed6843397be90fa35acf2f3ecfbb0/aiohttp-3.14.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:b0a1274ee4ee6203c15a341cce87f9f3df8151d5496047b3608bb62bd969cffc", size = 1857111, upload-time = "2026-10-05T17:42:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7f/4f5d3cee3c23b57119aebba5174962fed0bc493f37f38edab2f5730411b6/aiohttp-3.14.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:69ce2407951516f413adf3132b88451eababd710ce631f83c81a9603a3640253", size = 1892123, upload-time = "2026-10-05T17:42:31.786Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2e/5a6a1f014c4e17d58d4e935d0dd64e612f4eaa2f33cca2475b48ca5e3fb5/aiohttp-3.14.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:b84d3d057c60eb8e0c2b457a1203e868d82709d3f0c272a4975dfd88c2414939", size = 1898809, upload-time = "2026-10-05T17:42:34.34Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/d1cca5311a70fcc1c7c2082e02044578a69bcb10686db33a322df9fb849e/aiohttp-3.14.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4ecc84f69c9e217984f6877e832e4786a74567432c0bf2b7512919fbdfe18ade", size = 1704814, upload-time = "2026-10-05T17:42:37.047Z" }, + { url = "https://files.pythonhosted.org/packages/0a/cb/cd58eae366148a5b9b1baa233542151154f2b72cd32daa39fd9db97d0c0d/aiohttp-3.14.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:878f2d7950238b2b0a8cbb88054444a125f0cfbc77f54c811aca0483132a9be3", size = 1918390, upload-time = "2026-10-05T17:42:39.674Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d8/f5e6c4939432734033048ba631503edf4a1e693ee5e261773e169803bbe4/aiohttp-3.14.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:6ff9d3a0e6935f1cb0a6685f9f4b3f0d3e406456d7a434a03cf309b8cd5d7a2e", size = 1878321, upload-time = "2026-10-05T17:42:42.528Z" }, + { url = "https://files.pythonhosted.org/packages/2b/71/462957d6b5d4eb208f78e67745c41480bad2e738aa7caf1d9b07536920c1/aiohttp-3.14.4-cp315-cp315-win32.whl", hash = "sha256:e07dfd7cd360f20be26dc2487ad4c7f21b0b39fdd593e9ebf37d2424ad52defb", size = 485536, upload-time = "2026-10-05T17:42:45.223Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6e/48f59266a8b8c6420894ff59b8e74175252da67df85967b96997e96b7092/aiohttp-3.14.4-cp315-cp315-win_amd64.whl", hash = "sha256:921fc4f1ad549091bfc39cb5e93925b04f307f83517756a3111843851130efce", size = 512087, upload-time = "2026-10-05T17:42:47.717Z" }, + { url = "https://files.pythonhosted.org/packages/25/f5/46263b23cee4f312e28aaabbdec2e074c807700a12c9dfa18beff461f035/aiohttp-3.14.4-cp315-cp315-win_arm64.whl", hash = "sha256:5be8af106f96fd625f6c264fa9faaaa9e0104089684d9015d2fa5b6eba6407c7", size = 494709, upload-time = "2026-10-05T17:42:50.615Z" }, + { url = "https://files.pythonhosted.org/packages/77/8d/e3b1d406c95a8fe436ae2e5f01ee9591196c6c22c44c645b24f20d73eaea/aiohttp-3.14.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:e69727ec04a4608200ff32ec29478299df7657e2eb78c416ae338d0ca6662596", size = 856014, upload-time = "2026-10-05T17:42:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/5d/fc/e9e78d1097830040bc0a4b8af073379f964064bfa7f0a852d774f96f3168/aiohttp-3.14.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f8b658dd3ef2ebd7708b311ed96ff7cb5edaca001a30018a091504ef492bcb1c", size = 567724, upload-time = "2026-10-05T17:42:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ad/c813a705a1da1c111c30723b52e1efb67763e9f0b3b54a418cb419616e7a/aiohttp-3.14.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3749e018123df1205161a73ba96d336e17e9cf6b947295e53a43571de420e7b1", size = 567888, upload-time = "2026-10-05T17:42:59.048Z" }, + { url = "https://files.pythonhosted.org/packages/c2/1f/7676f46aa24554a45686853c61552d02d67ade2b4c51adfe8b2ec4fdecd2/aiohttp-3.14.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f28a3c4fca436b2e594090e7cd22558f5e93a30ca18516c436de982da6fc25a", size = 2054356, upload-time = "2026-10-05T17:43:01.781Z" }, + { url = "https://files.pythonhosted.org/packages/44/79/b9ef7d7442061e0a3125990d6021230b30361a972f7b8ebfb011042877ce/aiohttp-3.14.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e099aae21246992a1d84303ff262262d054c474e46cace33b0d5a69810c71877", size = 1937678, upload-time = "2026-10-05T17:43:04.492Z" }, + { url = "https://files.pythonhosted.org/packages/5c/98/5927a31540c8e6d0542effead5bd5c56e749a9d99d84e902fb80ae906ef8/aiohttp-3.14.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fafb521891e646d9cf5da89f01c75e7159d08be7254a024149630a698be28251", size = 2026705, upload-time = "2026-10-05T17:43:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/62/bd/1be001ed97dcfa57a292188c38979fb78be795b960b50f02660587bffa06/aiohttp-3.14.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1d385db2cd154bce0b3dd451cf2a52bec91ebb7be2c3a5d06b14b4391631d807", size = 2147075, upload-time = "2026-10-05T17:43:10.141Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f7/d59628ccedd7c86db4c1f904467d1c80fed6cc10a1831ed5d75ff12e5a71/aiohttp-3.14.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ae8a244889d8a53549851bf595b9f382f64acca57e8762da1ecb26ba786828f7", size = 2005016, upload-time = "2026-10-05T17:43:12.848Z" }, + { url = "https://files.pythonhosted.org/packages/3c/dc/472db3ab1daafa12a61d7f83314f85f502b99c143c78d66554a5e24361fd/aiohttp-3.14.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:681dde68ff8d8d5e7d5abcad4feb45bfb457a218310c919825e21fc36cac3d0b", size = 1796363, upload-time = "2026-10-05T17:43:15.562Z" }, + { url = "https://files.pythonhosted.org/packages/08/61/6f9834b131b9a42e30d68c469c196033d3cae2d8fe340d4668f362c4fd1f/aiohttp-3.14.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b91a6441fcaed88ca7bca67726b189332df3ceb6ac0dfc98dcb9654bb723288c", size = 1974240, upload-time = "2026-10-05T17:43:18.884Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/b8eb2a276685ef1da7f7bc15348404b8b9ee12b8f32f2cd8895385cd1589/aiohttp-3.14.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:f15dbbac49bc15eaff95c0b8166525c4050996af578c7be141e145f40ee2cf24", size = 1973387, upload-time = "2026-10-05T17:43:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/5b/32/ad559b2d6817e85f7cb294d8641e975e64b526f4da98ffe941c9af67a047/aiohttp-3.14.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:7abae043e87692d1c2adda1fab501bb0612d28c4a23eb082005c1e684f792dac", size = 1968766, upload-time = "2026-10-05T17:43:24.544Z" }, + { url = "https://files.pythonhosted.org/packages/60/67/4a16e398140bdabb363428b9f439af3f77706a6df8bea6e626bbde53c2ab/aiohttp-3.14.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:21f4624fb1051fc1aa10d57a636a80e829f7d0417462d6f1ce380b08fe1a904c", size = 1788193, upload-time = "2026-10-05T17:43:27.467Z" }, + { url = "https://files.pythonhosted.org/packages/a9/38/17b326da7f52e530a675d789ade3e727a1b3db9959722f6049b9c392321a/aiohttp-3.14.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:2e8dc3a0fdaf9d12b954d3d06cc4c355d91b17fb6c671715b593624ca4c379f8", size = 1999645, upload-time = "2026-10-05T17:43:30.32Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/91b87961250010e05eb3adabaf0d98e4229ee7543147c265e834922e1b0b/aiohttp-3.14.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1410b6f6e3d52ea7c02c950496522ce6e85d5543bf154bc80a6141e505251cfa", size = 1970998, upload-time = "2026-10-05T17:43:33.364Z" }, + { url = "https://files.pythonhosted.org/packages/72/66/9bcf2c2db7b75a3b51c11470ceb67941d028727d674276ff5d5120adc87e/aiohttp-3.14.4-cp315-cp315t-win32.whl", hash = "sha256:a65189a89f3e621f7c27a968b5131bbca29126e2b8517dc72b205747a39d4c96", size = 504036, upload-time = "2026-10-05T17:43:36.212Z" }, + { url = "https://files.pythonhosted.org/packages/e2/20/349db4af12dbc16070d32028d30c2067123b4520f3dcb2d9013524efeb4f/aiohttp-3.14.4-cp315-cp315t-win_amd64.whl", hash = "sha256:2b2c95f5f769eec92b552969db1f79ea0282058ce5131ed0f2fa540b4af3f14e", size = 531284, upload-time = "2026-10-05T17:43:39.362Z" }, + { url = "https://files.pythonhosted.org/packages/26/21/544c711bc5fad611278ebd153bdcab1bffce8b8581f8ec9096b275948a3c/aiohttp-3.14.4-cp315-cp315t-win_arm64.whl", hash = "sha256:54209fff79346ee1ef0d5cbf92fa80a5bb37396406bb4d932e5107f9c29b2d3f", size = 510623, upload-time = "2026-10-05T17:43:42.15Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/5e1f8446545be98e9416af59c396b1ed1f9f3294afb0a40ee3a709889273/aiohttp-3.14.4-py3-none-any.whl", hash = "sha256:5c6758ba62aea282c537179cfc8474a90f2b5f7b089cc5ff66d8920d86a9bfdd", size = 279487, upload-time = "2026-10-05T17:43:44.905Z" }, ] [[package]] @@ -330,11 +387,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] @@ -757,94 +814,116 @@ wheels = [ [[package]] name = "yarl" -version = "1.22.0" +version = "1.25.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, - { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, - { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, - { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, - { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, - { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, - { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, - { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, - { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, - { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, - { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, - { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, - { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, - { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, - { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, - { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, - { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, - { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, - { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, - { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, - { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, - { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, - { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, - { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, - { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, - { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, - { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, - { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, - { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, - { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, - { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, - { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, - { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, - { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, - { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, - { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, - { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, - { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, - { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, - { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, - { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, - { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, - { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, - { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, - { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, - { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, - { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, - { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, - { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, - { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, - { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, - { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, - { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, - { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" }, + { url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" }, + { url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" }, + { url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" }, + { url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" }, + { url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/2f3129bbcc9a5c8ba12cc2b29d8060a3bab9c8043c456cfd4b5ca3188890/yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5", size = 143623, upload-time = "2026-09-15T19:31:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/f1bc3390fdca352826676b531d0712736f156919090206700421d46b2c37/yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26", size = 104011, upload-time = "2026-09-15T19:31:40.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/aa/50acc5c3e5da04172ae3c281c75405af4d2ca911e16120ab0563f4dffb66/yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec", size = 103677, upload-time = "2026-09-15T19:31:42.46Z" }, + { url = "https://files.pythonhosted.org/packages/30/d2/7d1e0ab9f8390e1fbcede5a6dbf70d23c96ad09b8c5567f3a514d1ddb0e2/yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc", size = 115392, upload-time = "2026-09-15T19:31:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/71/e1/5ba1e3a2a22139213655e760919038e8ed7e2d4a99826d0bbddb3beb96e5/yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184", size = 107493, upload-time = "2026-09-15T19:31:46.273Z" }, + { url = "https://files.pythonhosted.org/packages/f5/53/780653d5e0f73831f467cf13548912e5eec97f21dc49fc8daf21da027df4/yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651", size = 122537, upload-time = "2026-09-15T19:31:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/03/92/d54fa70236c6036271c9c9c09fd978df5cbe3ef49ef6c46e9b833476d215/yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df", size = 126170, upload-time = "2026-09-15T19:31:50.872Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b7/a82a49bf88340b837ef6972b508a1604ae377b9e6904b46b10cf5f1cf925/yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab", size = 117012, upload-time = "2026-09-15T19:31:53.189Z" }, + { url = "https://files.pythonhosted.org/packages/ef/78/5d684b411e3f3602464ee9b538db48205038f8605872985f61efb809ced0/yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3", size = 114950, upload-time = "2026-09-15T19:31:55.694Z" }, + { url = "https://files.pythonhosted.org/packages/2f/11/51d82b852c64f7fad0fc7a7ff3031517204887e874c722bbca839c0b23ac/yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a", size = 115428, upload-time = "2026-09-15T19:31:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/9d1978049bf646b9ea918313926453c6901b71c92f097467777d47d36a88/yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b", size = 108428, upload-time = "2026-09-15T19:32:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/43/35/7b8f1ebb45d7ec3dda7d1909bf44f458de41ef91e2937f107733582a5166/yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a", size = 121961, upload-time = "2026-09-15T19:32:02.436Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/d8b689ab7ca26edeb85f6ff28812aac7a25376eefc1780e303a7bfbaceff/yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a", size = 114961, upload-time = "2026-09-15T19:32:04.456Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/1a1798ea4dc6b7ee3260010a27907ebc697c95dae99817d817ed446d24aa/yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98", size = 120036, upload-time = "2026-09-15T19:32:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/91/8d/b1b35ed7903da6669b1d367cb2c09436acd4ff508029b4f39a0c0c2058fc/yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa", size = 117276, upload-time = "2026-09-15T19:32:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8f/4db01cef62caff0d7a4593ed694fb8a41a27a11158cab80d290221f13e57/yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9", size = 101945, upload-time = "2026-09-15T19:32:11.545Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/3ce00497c5c0babb74d4130c10c3828ccd215b4819d12020c42429f991ac/yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe", size = 97270, upload-time = "2026-09-15T19:32:14.127Z" }, + { url = "https://files.pythonhosted.org/packages/80/cf/54023edfab7aa773b860503db0c56e962ccab0922803ee97988c176ea090/yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e", size = 143975, upload-time = "2026-09-15T19:32:16.416Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a8/e6c1be0e6761d0f2d10bbf33a3e1e02b99dc83874d92945d7b461a72481e/yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f", size = 104018, upload-time = "2026-09-15T19:32:18.364Z" }, + { url = "https://files.pythonhosted.org/packages/6e/bb/dda344765ffd3430afe1a1c66c866a57fae67786537d4f14607df6505ac1/yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac", size = 104156, upload-time = "2026-09-15T19:32:20.459Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5f/ed1538bcd06009fe990d6d283dd7667f639e62a81e35c6d8c6ef6c08fb3c/yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c", size = 116025, upload-time = "2026-09-15T19:32:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/a2/af/2185daf56b99830d3356ecfada46faaa49945de6626e842b7728088d4980/yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c", size = 106985, upload-time = "2026-09-15T19:32:24.749Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/bc1ae564fb4b04a30b6a8f250e787772581c57e4c3d5cf07ac3359de3103/yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743", size = 123030, upload-time = "2026-09-15T19:32:27.084Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3e/e2afcde10d74e53b3fa889960991efb3019beda2b1682a01de720a302056/yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f", size = 126765, upload-time = "2026-09-15T19:32:29.332Z" }, + { url = "https://files.pythonhosted.org/packages/a2/be/415b00c0fe5a0615b062a456b26623d7ec91c2bee20faea1a14045aa0469/yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57", size = 117199, upload-time = "2026-09-15T19:32:31.49Z" }, + { url = "https://files.pythonhosted.org/packages/97/27/3d8c63ddd3e8bcfd033748ab93876678ce59bacd66e4cb1ed851c9c5b37e/yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453", size = 115187, upload-time = "2026-09-15T19:32:34.137Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/7a81d0be1a502a26a0d4326c6f2ecb736c824f570ea1c6529f2b0b227b50/yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc", size = 116085, upload-time = "2026-09-15T19:32:36.438Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/39fff459916aa0fab42215dc47b759586fd80f94aa56dfc4a7c15ba6e0dc/yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f", size = 107996, upload-time = "2026-09-15T19:32:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/c0/39/80b9a55a3335590451d9ecf3eb593a8c635351f4c905ef056d7e8a8fd9e7/yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8", size = 122549, upload-time = "2026-09-15T19:32:41.151Z" }, + { url = "https://files.pythonhosted.org/packages/42/7d/a179c6757818bb59372a4adafd09f7f26a3b4a0f04c3ae404b544c0b0c82/yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868", size = 115107, upload-time = "2026-09-15T19:32:43.072Z" }, + { url = "https://files.pythonhosted.org/packages/32/2b/a773ac867e4ab53a98ed98e5cefe3bae31e6f550252ca9d1de266f1a40c5/yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62", size = 120666, upload-time = "2026-09-15T19:32:45.061Z" }, + { url = "https://files.pythonhosted.org/packages/bc/41/52be6505e85b0f76b4f85b01b5de7e06a0512201abc2c95e14e099549174/yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233", size = 117505, upload-time = "2026-09-15T19:32:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/f5/01/349c0386caedbbe488d519f252df54efac8a1459282d466c474bdd84a620/yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b", size = 103446, upload-time = "2026-09-15T19:32:49.615Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f0/8ec63180f77912f0dc4e5a42760cb8c08d20da1d5ace3578a01b84d1f3d8/yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414", size = 99159, upload-time = "2026-09-15T19:32:51.686Z" }, + { url = "https://files.pythonhosted.org/packages/47/7d/92d2220d6886b70ab1ed8579533ac2af2dfac716d5d929001daff7986df9/yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa", size = 150071, upload-time = "2026-09-15T19:32:53.911Z" }, + { url = "https://files.pythonhosted.org/packages/64/fc/b245e448124bcda9340df38e3553fa222b50260fca027a84095e9bd8642d/yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287", size = 106780, upload-time = "2026-09-15T19:32:56.443Z" }, + { url = "https://files.pythonhosted.org/packages/51/e2/9a6ce2e334ebf218a30335ae76fb1696459430d42f733b8cb0d7d65b84d3/yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94", size = 107361, upload-time = "2026-09-15T19:32:58.827Z" }, + { url = "https://files.pythonhosted.org/packages/ed/70/66e8c76b569b450d16e190f15071c916c3df70b0e33927e415ac497cf0c2/yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9", size = 114396, upload-time = "2026-09-15T19:33:02.24Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/0d82838a05c57fdc05bc8b66e8c92dcc0df15e27463a5f163142d521c682/yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0", size = 104882, upload-time = "2026-09-15T19:33:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/86/d4/ea08615c4edaa6049a13a2f1128944d068d1893abda7d708d4d7ea01599a/yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa", size = 119485, upload-time = "2026-09-15T19:33:06.583Z" }, + { url = "https://files.pythonhosted.org/packages/1a/82/0898bdce9b1ae403b308b9c733d0d24af4a3464270c2c081f457b16c3e0d/yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b", size = 122490, upload-time = "2026-09-15T19:33:08.653Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/97d79b81c342b78246cfedb74809e68841f3198d21653e10d3232bd9c622/yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6", size = 115336, upload-time = "2026-09-15T19:33:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/2577896554cd310dc470adb6da0b7dd0b435cb63e2565204a7ac240e504c/yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee", size = 111825, upload-time = "2026-09-15T19:33:13.204Z" }, + { url = "https://files.pythonhosted.org/packages/29/6b/7ac49d8ba84a5c4bd73415a4c949d22c749cb3762579b3d50e48019a78aa/yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0", size = 114655, upload-time = "2026-09-15T19:33:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/e5/18/e5942a16723f5b72f9b1297fd5a85a54f6300cd15c0dcb5005b90cd89156/yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7", size = 106395, upload-time = "2026-09-15T19:33:17.599Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2d/549fa46240781513ebc47ae7eb418df428a163a2a3d644cc9cbb3ecb7846/yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b", size = 119277, upload-time = "2026-09-15T19:33:19.973Z" }, + { url = "https://files.pythonhosted.org/packages/76/16/4763f78dcdc0b3b9fb3842b04afe72b9320857c6a69300c62a0eab03d119/yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d", size = 112504, upload-time = "2026-09-15T19:33:22.464Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b4/974e3edfe0d188393ce1cb9de400111c63fe61f4eb3b772a500d84c970d1/yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3", size = 116243, upload-time = "2026-09-15T19:33:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/b0/aa/157b940428da80c104ca09666a740e51c94963df65d5b112e06b52e4d7a8/yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3", size = 115822, upload-time = "2026-09-15T19:33:26.886Z" }, + { url = "https://files.pythonhosted.org/packages/7e/af/19fbdce41412e1b96825544cc52cd7029d3724655d0988237972f078bd29/yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371", size = 107386, upload-time = "2026-09-15T19:33:29.009Z" }, + { url = "https://files.pythonhosted.org/packages/2a/99/f6431c8968e89be608d74b28ae2d024521b2953f27dd44e0dece5e04f67a/yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6", size = 102094, upload-time = "2026-09-15T19:33:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3b/4f51eab40c2eabea6c3d5b121dff4b8988dc35087732ffede12d2be8b8dd/yarl-1.25.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc", size = 143875, upload-time = "2026-09-15T19:33:33.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/05/bbd58fc063f5f299a883f810760b265ca26c8167c91cb9a494d0fe2387e1/yarl-1.25.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc", size = 104028, upload-time = "2026-09-15T19:33:36.22Z" }, + { url = "https://files.pythonhosted.org/packages/12/ee/2fba0aecb52e7020e189f684148783aa0b9cfa3b3bfb0b400646eef70ad4/yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b", size = 104041, upload-time = "2026-09-15T19:33:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/38/35/884beab53ed88c7247d1671972b5ef116f7351fe0c7e6de8c3558372cb16/yarl-1.25.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459", size = 116018, upload-time = "2026-09-15T19:33:44.265Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/1a91b685afb55cc18608443ace95280e96e263a97565811732b6788d3269/yarl-1.25.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d", size = 107033, upload-time = "2026-09-15T19:33:46.45Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c1/58b379fcb1d68d907b7fcf75200c44321896509b2a6a74abbb4b19d864d2/yarl-1.25.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26", size = 123257, upload-time = "2026-09-15T19:33:48.631Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2d/1fe96cf5c2aeab10095e48f38585cf5a8451fb7253234822398e52aa5336/yarl-1.25.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273", size = 126745, upload-time = "2026-09-15T19:33:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/ad/60/8674394ce43f4dadae573a1d6f451716438e9eab7d7fe8d643c673a32d85/yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf", size = 117255, upload-time = "2026-09-15T19:33:53.456Z" }, + { url = "https://files.pythonhosted.org/packages/2f/72/0faa30e02605d56127d42bb987dcc97da3863b7bf70b9bfbf5f739c05e30/yarl-1.25.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57", size = 115166, upload-time = "2026-09-15T19:33:55.669Z" }, + { url = "https://files.pythonhosted.org/packages/8c/90/9a46eac564c437e128285c5c1d7bb385d268394e9209d84f6bf4a14471ef/yarl-1.25.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3", size = 116082, upload-time = "2026-09-15T19:33:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/ad/38/4b1a686a3758878f93d2f1ea943f5a165f3555769cd16e761cfd0efdca17/yarl-1.25.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2", size = 108048, upload-time = "2026-09-15T19:34:00.25Z" }, + { url = "https://files.pythonhosted.org/packages/da/14/f348eb967f31a58348612a2b93bd8a2ab664548e2b5e879cac7f592f7201/yarl-1.25.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9", size = 122775, upload-time = "2026-09-15T19:34:02.455Z" }, + { url = "https://files.pythonhosted.org/packages/ab/e9/4f7b79700f88cb9e8bb66f8b54f9bce1844c013a2c39fdc47112e9334c95/yarl-1.25.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f", size = 115096, upload-time = "2026-09-15T19:34:05.283Z" }, + { url = "https://files.pythonhosted.org/packages/cf/37/f9cb020331997d3eb887bd28d5410ecfd3d80bf163c23d7cec490d78dade/yarl-1.25.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b", size = 120655, upload-time = "2026-09-15T19:34:07.382Z" }, + { url = "https://files.pythonhosted.org/packages/12/83/52fceb22891a41f168db7ec22fd1d81e06b6a0b8d9f70921bd3e785defd0/yarl-1.25.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c", size = 117488, upload-time = "2026-09-15T19:34:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5c/ce6c4ff1247fcbe4b33d462c23a097106d909b173fde7042bc52290466e2/yarl-1.25.1-cp315-cp315-win_amd64.whl", hash = "sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee", size = 103434, upload-time = "2026-09-15T19:34:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/766a906b0704fb26d52b19dc22bed48a8ba0544203b70dcf44da350e8194/yarl-1.25.1-cp315-cp315-win_arm64.whl", hash = "sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d", size = 99155, upload-time = "2026-09-15T19:34:15.132Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d3/a1d09b32cb6ab14f66b44939f5b4255b8b9e747aef3974af1d5d80ccc2fd/yarl-1.25.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8", size = 149284, upload-time = "2026-09-15T19:34:17.452Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/fe554d879692650bca70bfbc0df124e82e4d2bb7456f698c7756f1279a96/yarl-1.25.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4", size = 106399, upload-time = "2026-09-15T19:34:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4b/e7af56177ac8d40094c82d7728224c0b8472157d50d362e5fb3b014b2bc8/yarl-1.25.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec", size = 106968, upload-time = "2026-09-15T19:34:23.619Z" }, + { url = "https://files.pythonhosted.org/packages/da/4f/2df41fd738d46f23ef829ae8b4468d94bb6070038fc6dfab6165ed44fea8/yarl-1.25.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487", size = 114717, upload-time = "2026-09-15T19:34:25.879Z" }, + { url = "https://files.pythonhosted.org/packages/69/ea/002b66df53bbd1aed1c23358ff99c9bdc744fe3f4d2740e1b2fdd7192885/yarl-1.25.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8", size = 105198, upload-time = "2026-09-15T19:34:28.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7c/95c8bc0c8f97d71e59c94525ad60d76f5c57d3f2820f08137ca8b9f0542a/yarl-1.25.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75", size = 120271, upload-time = "2026-09-15T19:34:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/6fe9da56ec77927baa669fd86c39c567ce6205bab53d581082c6744c8ae7/yarl-1.25.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3", size = 123572, upload-time = "2026-09-15T19:34:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/8b/83/35f222d17fa70a14c7c74fdf112ccf5515e0c2a87082b1f9b99f7693bf57/yarl-1.25.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225", size = 115228, upload-time = "2026-09-15T19:34:35.344Z" }, + { url = "https://files.pythonhosted.org/packages/da/6f/fbaaf619423578a7d898d0f226ae47bc1906c293865c416d83c17b828b0e/yarl-1.25.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209", size = 111618, upload-time = "2026-09-15T19:34:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/ba/78/7383278f1b3cf8e0496bd95b3281a7b09b89217b6b428db24c6b99b3deca/yarl-1.25.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3", size = 114839, upload-time = "2026-09-15T19:34:40.099Z" }, + { url = "https://files.pythonhosted.org/packages/62/49/5506e5b6d29aab91bd845cc9016d88c3d3f81b81bc242b8100bdd5737825/yarl-1.25.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d", size = 106212, upload-time = "2026-09-15T19:34:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/19877c193b7c5929f4b07118c18bbe390f3fadd0f59dd98c0cd12b31fa5c/yarl-1.25.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c", size = 119985, upload-time = "2026-09-15T19:34:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6c/0a46fbbf9ecbcbd0cc20d2193394254b9e19817f22c814aab60f99847400/yarl-1.25.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde", size = 112081, upload-time = "2026-09-15T19:34:47.45Z" }, + { url = "https://files.pythonhosted.org/packages/ef/30/93f5d471230c74ccd06255d0842739f86551f937f9e63a5e947853c6244a/yarl-1.25.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64", size = 116995, upload-time = "2026-09-15T19:34:49.972Z" }, + { url = "https://files.pythonhosted.org/packages/2b/80/c386593035ee3f9c6c6af0847b5578f2830c674794a9d7701b744a3ebd42/yarl-1.25.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f", size = 115570, upload-time = "2026-09-15T19:34:52.628Z" }, + { url = "https://files.pythonhosted.org/packages/38/02/eef443559563ef8f2e10469387b8b1e97cb5efee95b288a56da60801f7ee/yarl-1.25.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd", size = 106811, upload-time = "2026-09-15T19:34:54.935Z" }, + { url = "https://files.pythonhosted.org/packages/88/91/41e284ca2cf5211e05dae031d126a3668aea88fa759df56e7e35c6ad25ba/yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25", size = 101804, upload-time = "2026-09-15T19:34:57.231Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, ] From f60a291eea74a690cb78824f79050c8d2c621b65 Mon Sep 17 00:00:00 2001 From: Mike Zupper Date: Tue, 27 Oct 2026 11:39:00 -0400 Subject: [PATCH 6/7] Add pre-sign payment intent and signing-error callbacks --- CHANGELOG.md | 3 + src/livepeer_gateway/__init__.py | 2 + src/livepeer_gateway/live_runner.py | 34 ++++++++-- tests/test_live_runner.py | 97 +++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1added..37f5b2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +- `call_runner(on_payment_attempt=..., on_payment_error=...)` hooks for durable + pre-sign intent and structured signing failures. Cancellation remains prompt; + an unresolved intent does not imply that the signer did not charge. - `LiveRunnerRegistration.stop_session()` to stop reserved sessions - `LiveRunnerSessionEvent.session_token` field for session management - `call_runner(on_payment_session=...)` callback for each new payment session diff --git a/src/livepeer_gateway/__init__.py b/src/livepeer_gateway/__init__.py index ad4e80a..17703e7 100644 --- a/src/livepeer_gateway/__init__.py +++ b/src/livepeer_gateway/__init__.py @@ -28,6 +28,7 @@ from .errors import RunnerRejection from .live_runner import ( LivePaymentSessionCallback, + LivePaymentErrorCallback, LiveRunnerCallResult, LiveRunnerCallStream, LiveRunnerGPU, @@ -81,6 +82,7 @@ "LivePaymentChallenge", "LivePaymentSession", "LivePaymentSessionCallback", + "LivePaymentErrorCallback", "LiveRunnerProxy", "RunnerPayload", "StreamPayload", diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 48e77ed..8564324 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -90,6 +90,7 @@ class LiveRunnerSessionEvent: LiveRunnerSessionCallback = Callable[[LiveRunnerSessionEvent], None | Awaitable[None]] LivePaymentSessionCallback = Callable[[LivePaymentSession], None | Awaitable[None]] +LivePaymentErrorCallback = Callable[[LivePaymentSession, Exception], None | Awaitable[None]] @dataclass(frozen=True) @@ -785,6 +786,8 @@ async def call_runner( timeout: float = ..., max_payment_challenge_retries: int = ..., on_payment_session: LivePaymentSessionCallback | None = ..., + on_payment_attempt: LivePaymentSessionCallback | None = ..., + on_payment_error: LivePaymentErrorCallback | None = ..., stream: Literal[False] = False, ) -> LiveRunnerCallResult: pass @@ -804,6 +807,8 @@ async def call_runner( timeout: float = ..., max_payment_challenge_retries: int = ..., on_payment_session: LivePaymentSessionCallback | None = ..., + on_payment_attempt: LivePaymentSessionCallback | None = ..., + on_payment_error: LivePaymentErrorCallback | None = ..., stream: Literal[True], ) -> LiveRunnerCallStream: pass @@ -822,6 +827,8 @@ async def call_runner( timeout: float = 5.0, max_payment_challenge_retries: int = 3, on_payment_session: LivePaymentSessionCallback | None = None, + on_payment_attempt: LivePaymentSessionCallback | None = None, + on_payment_error: LivePaymentErrorCallback | None = None, stream: bool = False, ) -> LiveRunnerCallResult | LiveRunnerCallStream: """Call a runner once and return its result (or a live stream if ``stream=True``). @@ -830,9 +837,18 @@ async def call_runner( paid via the signer and retried (up to ``max_payment_challenge_retries``), one job, one upfront payment. Raises ``LivepeerHTTPError`` on non-402 errors. - After a new payment session is obtained, `on_payment_session` is called. - Metered renewals do not invoke it. The callback may be sync or - async. Its exceptions propagate and abort the call. + Before each new session's first signing request, ``on_payment_attempt(session)`` + is awaited. Use it to durably record intent; its failure prevents signing. + After a validated payment response, ``on_payment_session(session)`` runs before + dispatch. If signing or response validation raises an Exception, + ``on_payment_error(session, error)`` runs before any challenge-refresh retry. + It receives the original error; a failed or missing response is not proof that + no payment occurred. Cancellation propagates without invoking the error hook, + so a recorded intent may remain unresolved. + + All three callbacks may be sync or async; their exceptions abort the call. + They apply to new sessions, not metered renewals. Hooks should bound their own + I/O. Signing is never shielded or drained after cancellation. ``application/json`` and ``+json`` types parse into ``result.data``; anything else (an image, ndjson) comes back unparsed in ``result.content`` + ``result.content_type``. @@ -883,10 +899,18 @@ async def call_runner( app=runner.app if runner is not None else None, max_price=max_price.to_json() if max_price is not None else None, ) + if on_payment_attempt is not None: + result = on_payment_attempt(payment_session) + if inspect.isawaitable(result): + await result try: payment = await payment_session.get_payment() - except SignerRefreshRequired as e: - if attempt + 1 >= attempts: + except Exception as e: + if on_payment_error is not None: + result = on_payment_error(payment_session, e) + if inspect.isawaitable(result): + await result + if not isinstance(e, SignerRefreshRequired) or attempt + 1 >= attempts: raise # Could happen if embedded payment params expire; just retry in this case. _LOG.info( diff --git a/tests/test_live_runner.py b/tests/test_live_runner.py index 32a58d6..241b52f 100644 --- a/tests/test_live_runner.py +++ b/tests/test_live_runner.py @@ -676,6 +676,103 @@ def challenge(manifest): challenge=challenge, ) + async def test_payment_intent_is_awaited_before_signing(self, payment_call) -> None: + seen = [] + + async def intent(session): + assert payment_call.signer.await_count == 0 + await asyncio.sleep(0) + seen.append(session.manifest_id) + + await call_runner(**payment_call.kwargs, on_payment_attempt=intent) + assert seen == ["manifest-1"] + assert payment_call.signer.await_count == 1 + + async def test_signing_error_reports_the_attempt_before_refresh(self, payment_call) -> None: + failure = SignerRefreshRequired("expired challenge") + payment_call.signer.side_effect = [failure, payment_call.signer.return_value] + payment_call.transport.side_effect = [ + payment_call.challenge("expired"), payment_call.challenge("fresh"), payment_call.reply, + ] + seen = [] + + async def failed(session, error): + assert payment_call.transport.await_count == 1 + seen.append((session.manifest_id, error)) + + await call_runner(**payment_call.kwargs, on_payment_error=failed) + assert seen == [("expired", failure)] + + async def test_cancellation_does_not_wait_for_an_error_hook(self, payment_call) -> None: + signing = asyncio.Event() + release = asyncio.Event() + + async def blocked_signer(*args, **kwargs): + signing.set() + await release.wait() + + async def blocked_error(*args): + await release.wait() + + error_hook = mock.AsyncMock(side_effect=blocked_error) + payment_call.signer.side_effect = blocked_signer + task = asyncio.create_task(call_runner(**payment_call.kwargs, on_payment_error=error_hook)) + try: + await asyncio.wait_for(signing.wait(), 1) + task.cancel() + done, _ = await asyncio.wait([task], timeout=0.1) + assert task in done + with pytest.raises(asyncio.CancelledError): + await task + error_hook.assert_not_called() + finally: + release.set() + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + @pytest.mark.parametrize("payment_call", [False, True], indirect=True) + async def test_sync_intent_callback_precedes_signer_and_success(self, payment_call) -> None: + seen = [] + + def intent(session): + assert payment_call.signer.await_count == 0 + seen.append(("intent", session.manifest_id)) + + result = await call_runner( + **payment_call.kwargs, on_payment_attempt=intent, + on_payment_session=lambda session: seen.append(("paid", session.manifest_id)), + ) + if payment_call.kwargs["stream"]: + await result.aclose() + assert seen == [("intent", "manifest-1"), ("paid", "manifest-1")] + + @pytest.mark.parametrize("hook", ["on_payment_attempt", "on_payment_error"]) + @pytest.mark.parametrize("error", [RuntimeError("storage failed"), SignerRefreshRequired("callback")]) + async def test_intent_hook_errors_abort_without_retry(self, payment_call, hook, error) -> None: + if hook == "on_payment_error": + payment_call.signer.side_effect = SignerRefreshRequired("signer") + with pytest.raises(type(error)) as raised: + await call_runner(**payment_call.kwargs, **{hook: mock.AsyncMock(side_effect=error)}) + assert raised.value is error + assert payment_call.signer.await_count == (1 if hook == "on_payment_error" else 0) + assert payment_call.transport.await_count == 1 + + @pytest.mark.parametrize("failure", [ + LivepeerHTTPError(500, "https://signer.example.com"), + {"payment": "ticket", "segCreds": None, "state": {}}, + ]) + async def test_error_hook_receives_the_original_signing_error(self, payment_call, failure) -> None: + if isinstance(failure, Exception): + payment_call.signer.side_effect = failure + else: + payment_call.signer.return_value = failure + seen = [] + with pytest.raises(LivepeerGatewayError) as raised: + await call_runner(**payment_call.kwargs, on_payment_error=lambda session, error: seen.append( + (session.manifest_id, error) + )) + assert seen == [("manifest-1", raised.value)] + @pytest.mark.parametrize( "payment_call,async_callback,payment_unit", [(False, False, "fixed"), (True, True, "seconds")], From 202721c7664d0fdd1f4bc5324216d6a816cfaf0f Mon Sep 17 00:00:00 2001 From: Mike Zupper Date: Tue, 27 Oct 2026 12:01:39 -0400 Subject: [PATCH 7/7] Preserve signer state when rejecting blank segment credentials --- CHANGELOG.md | 3 ++ src/livepeer_gateway/remote_signer.py | 9 +++- tests/test_live_payment_session.py | 70 +++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37f5b2e..d3fa904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Changed +- Preserve a valid signer state when rejecting blank segment credentials, so + subsequent metered renewals do not reuse the previous cursor. Blank segments + remain rejected and are never sent to the orchestrator. - `discover_orchestrators` is now asynchronous and must be awaited, including when given explicit orchestrator addresses. diff --git a/src/livepeer_gateway/remote_signer.py b/src/livepeer_gateway/remote_signer.py index 81f6ab1..7cdcfd9 100644 --- a/src/livepeer_gateway/remote_signer.py +++ b/src/livepeer_gateway/remote_signer.py @@ -221,7 +221,7 @@ async def _payment_request(self) -> GetPaymentResponse: ) seg_creds = data.get("segCreds") - if not isinstance(seg_creds, str) or not seg_creds: + if seg_creds is not None and not isinstance(seg_creds, str): raise PaymentError( f"GetPayment error: invalid 'segCreds' in response (url={url})" ) @@ -232,7 +232,14 @@ async def _payment_request(self) -> GetPaymentResponse: f"Remote signer response missing 'state' object (url={url})" ) + # A blank segment is unusable, but signing may have advanced the cursor. + # Preserve valid state before rejecting it so a metered renewal does not + # resubmit the previous cursor. Invalid types/state never replace it. self._state = state + if not seg_creds: + raise PaymentError( + f"GetPayment error: invalid 'segCreds' in response (url={url})" + ) return GetPaymentResponse(payment=payment, seg_creds=seg_creds) async def _refresh_payment_params(self) -> None: diff --git a/tests/test_live_payment_session.py b/tests/test_live_payment_session.py index d07bffe..a3cae96 100644 --- a/tests/test_live_payment_session.py +++ b/tests/test_live_payment_session.py @@ -7,6 +7,7 @@ from livepeer_gateway.errors import ( LivepeerHTTPError, + PaymentError, SignerRefreshRequired, ) from livepeer_gateway.remote_signer import ( @@ -33,6 +34,75 @@ def clear_signer_info_cache(self): yield get_signer_info.cache_clear() + @pytest.mark.parametrize("blank_segment", [None, ""]) + async def test_blank_segment_keeps_new_state_for_the_next_renewal( + self, blank_segment: str | None, + ) -> None: + session = LivePaymentSession( + "https://signer.example.com", type="live", challenge=_challenge(), + ) + signer = mock.AsyncMock(side_effect=[ + {"payment": "p1", "segCreds": "s1", "state": {"cursor": "one"}}, + {"payment": "p2", "segCreds": blank_segment, "state": {"cursor": "two"}}, + {"payment": "p3", "segCreds": "s3", "state": {"cursor": "three"}}, + ]) + posted = mock.AsyncMock() + with ( + mock.patch("livepeer_gateway.http.post_json", signer), + mock.patch("livepeer_gateway.http._post_empty", posted), + ): + await session.send_payment() + with pytest.raises(PaymentError, match="segCreds"): + await session.send_payment() + assert posted.await_count == 1, "never POST a blank segment to the orchestrator" + await session.send_payment() + + assert signer.await_args_list[2].args[1]["state"] == {"cursor": "two"} + assert posted.await_count == 2 + assert posted.await_args.kwargs["headers"]["Livepeer-Segment"] == "s3" + + @pytest.mark.parametrize("invalid", [ + {"payment": "p2", "segCreds": 123, "state": {"cursor": "two"}}, + {"payment": "p2", "segCreds": ""}, + {"payment": "p2", "segCreds": None, "state": []}, + {"payment": None, "segCreds": "", "state": {"cursor": "two"}}, + ]) + async def test_invalid_response_fields_do_not_replace_signer_state(self, invalid) -> None: + session = LivePaymentSession( + "https://signer.example.com", type="live", challenge=_challenge(), + ) + signer = mock.AsyncMock(side_effect=[ + {"payment": "p1", "segCreds": "s1", "state": {"cursor": "one"}}, + invalid, + {"payment": "p3", "segCreds": "s3", "state": {"cursor": "three"}}, + ]) + with mock.patch("livepeer_gateway.http.post_json", signer): + await session.get_payment() + with pytest.raises(PaymentError): + await session.get_payment() + await session.get_payment() + + assert signer.await_args_list[2].args[1]["state"] == {"cursor": "one"} + + @pytest.mark.parametrize("blank_segment", [None, ""]) + async def test_initial_blank_segment_keeps_state_without_accepting_payment( + self, blank_segment: str | None, + ) -> None: + session = LivePaymentSession( + "https://signer.example.com", type="live", challenge=_challenge(), + ) + signer = mock.AsyncMock(side_effect=[ + {"payment": "p1", "segCreds": blank_segment, "state": {"cursor": "one"}}, + {"payment": "p2", "segCreds": "s2", "state": {"cursor": "two"}}, + ]) + with mock.patch("livepeer_gateway.http.post_json", signer): + with pytest.raises(PaymentError, match="segCreds"): + await session.get_payment() + await session.get_payment() + + assert "state" not in signer.await_args_list[0].args[1] + assert signer.await_args_list[1].args[1]["state"] == {"cursor": "one"} + async def test_none_signer_exits_early(self) -> None: session = LivePaymentSession( None,