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/CHANGELOG.md b/CHANGELOG.md index a067f9f..d3fa904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### 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 +- `LivePaymentSession.manifest_id` for payment attribution +- Flexible request body types on call_runner and selectors. + ### 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. ### 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..6e9a06b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ include = [ "/CHANGELOG.md", "/LICENSE", "/README", + "/docs", "/examples", "/pyproject.toml", "/src", @@ -87,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/__init__.py b/src/livepeer_gateway/__init__.py index eb9274c..17703e7 100644 --- a/src/livepeer_gateway/__init__.py +++ b/src/livepeer_gateway/__init__.py @@ -27,6 +27,8 @@ ) from .errors import RunnerRejection from .live_runner import ( + LivePaymentSessionCallback, + LivePaymentErrorCallback, LiveRunnerCallResult, LiveRunnerCallStream, LiveRunnerGPU, @@ -37,7 +39,6 @@ LiveRunnerSessionCallback, LiveRunnerSessionEvent, LiveRunnerProxy, - LivePaymentSessionCallback, call_runner, create_proxy, create_trickle_channels, @@ -47,6 +48,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, @@ -80,7 +82,10 @@ "LivePaymentChallenge", "LivePaymentSession", "LivePaymentSessionCallback", + "LivePaymentErrorCallback", "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/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/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 95f6dcc..8564324 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -26,16 +26,17 @@ 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 from .remote_signer import ( - GetPaymentResponse, LivePaymentChallenge, LivePaymentSession, _freeze_headers, get_signer_info, ) +from .runner_payload import RunnerPayload, _StreamState, _stream_state _LOG = logging.getLogger(__name__) @@ -84,10 +85,12 @@ class LiveRunnerSessionEvent: event: Literal["reserved", "released"] timestamp: str | None raw: dict[str, Any] + session_token: str = "" LiveRunnerSessionCallback = Callable[[LiveRunnerSessionEvent], None | Awaitable[None]] LivePaymentSessionCallback = Callable[[LivePaymentSession], None | Awaitable[None]] +LivePaymentErrorCallback = Callable[[LivePaymentSession, Exception], None | Awaitable[None]] @dataclass(frozen=True) @@ -189,6 +192,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( @@ -220,10 +226,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 @@ -275,7 +287,6 @@ def __init__( runner_id: str = "", mode: str = "persistent", proxy: bool = False, - label: str = "", version: str = "", metadata: str = "", status: str = "ready", @@ -299,7 +310,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 @@ -310,7 +320,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 @@ -324,8 +334,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 @@ -371,6 +380,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, @@ -446,8 +472,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: @@ -564,8 +588,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) @@ -579,6 +607,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) @@ -587,14 +616,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) @@ -610,7 +633,6 @@ async def register_runner( runner_id: str = "", mode: str = "persistent", proxy: bool = False, - label: str = "", version: str = "", metadata: str = "", status: str = "ready", @@ -635,7 +657,6 @@ async def register_runner( runner_id=runner_id, mode=mode, proxy=proxy, - label=label, version=version, metadata=metadata, status=status, @@ -756,7 +777,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 = ..., @@ -764,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 @@ -774,7 +798,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 = ..., @@ -782,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 @@ -791,7 +818,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, @@ -799,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``). @@ -807,152 +837,183 @@ 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. - ``on_payment_session`` (sync or async) is called with each payment session the - signer signs, before the paid request goes out; if it raises, the request is not - sent. The signer charges when it signs, so a reported session can cost even if the - call then fails. A runner that answers 402 again after payment gets a new session; - metered calls keep paying on the one they have. ``session.manifest_id`` identifies - the orchestrator session each payment funds. + 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``. 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 = 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: + payment_session = LivePaymentSession( signer_url=signer_url or "", signer_headers=signer_headers, - max_price=max_price, - on_payment_session=on_payment_session, - ) - except _RunnerPaymentCallbackError as e: - raise e.error - 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, + 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, ) - challenge = None - continue - _validate_runner_payment(payment) - request_headers["Livepeer-Payment"] = payment.payment - request_headers["Livepeer-Segment"] = payment.seg_creds or "" - session_id = challenge.manifest_id + 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 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( + "Live runner reservation payment challenge needs refresh; retrying with a fresh challenge: %s", + e, + ) + 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 + 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: @@ -1003,71 +1064,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, - on_payment_session: LivePaymentSessionCallback | None, - app: str | None = None, -) -> tuple[LivePaymentSession, GetPaymentResponse]: - session: LivePaymentSession - - async def _report_payment() -> None: - if on_payment_session is None: - return - try: - result = on_payment_session(session) - if inspect.isawaitable(result): - await result - except Exception as e: - raise _RunnerPaymentCallbackError(e) from e - - callback_args: dict[str, Any] = {} - if on_payment_session is not None: - callback_args["_on_payment"] = _report_payment - 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, - **callback_args, - ) - payment_request = session.get_payment() - if on_payment_session is None: - payment = await payment_request - else: - payment_task = asyncio.create_task(payment_request) - try: - payment = await asyncio.shield(payment_task) - except asyncio.CancelledError: - drain = asyncio.gather(payment_task, return_exceptions=True) - while not drain.done(): - try: - await asyncio.shield(drain) - except asyncio.CancelledError: - continue - raise - return session, payment - - -class _RunnerPaymentCallbackError(Exception): - def __init__(self, error: Exception) -> None: - super().__init__(str(error)) - self.error = error - - -def _validate_runner_payment(payment: GetPaymentResponse) -> None: - 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") - - def _runner_payment_type( runner: LiveRunnerInstance | None, payment_unit: str | None = None, 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 9e008c7..7cdcfd9 100644 --- a/src/livepeer_gateway/remote_signer.py +++ b/src/livepeer_gateway/remote_signer.py @@ -1,11 +1,9 @@ from __future__ import annotations import asyncio -import inspect import logging -from collections.abc import Awaitable, Callable from dataclasses import dataclass, replace -from typing import Any, Optional +from typing import Any from .async_cache import async_lru_cache from .errors import ( @@ -23,7 +21,7 @@ @dataclass(frozen=True) class GetPaymentResponse: payment: str - seg_creds: Optional[str] = None + seg_creds: str | None = None @dataclass(frozen=True) @@ -50,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 @@ -123,7 +121,6 @@ def __init__( app: str | None = None, max_price: dict[str, Any] | None = None, max_refresh_retries: int = 3, - _on_payment: Callable[[], None | Awaitable[None]] | None = None, ) -> None: self._signer_url = signer_url self._signer_headers = _freeze_headers(signer_headers) @@ -133,11 +130,10 @@ def __init__( self._max_price = dict(max_price) if max_price is not None else None self._max_refresh_retries = max(0, int(max_refresh_retries)) self._state: dict[str, Any] | None = None - self._on_payment = _on_payment @property def manifest_id(self) -> str: - """The manifest ID from the 402 challenge this session pays for.""" + """The manifest ID of the payment challenge for this session.""" return self._challenge.manifest_id async def get_payment(self) -> GetPaymentResponse: @@ -170,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, @@ -227,12 +219,6 @@ async def _payment_request(self) -> GetPaymentResponse: raise PaymentError( f"GetPayment error: missing/invalid 'payment' in response (url={url})" ) - on_payment = self._on_payment - self._on_payment = None - if on_payment is not None: - result = on_payment() - if inspect.isawaitable(result): - await result seg_creds = data.get("segCreds") if seg_creds is not None and not isinstance(seg_creds, str): @@ -246,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/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/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/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/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 diff --git a/tests/test_call_runner_uploads.py b/tests/test_call_runner_uploads.py new file mode 100644 index 0000000..21a6c75 --- /dev/null +++ b/tests/test_call_runner_uploads.py @@ -0,0 +1,641 @@ +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={"payment": "paid", "segCreds": "segment", "state": {}}) + monkeypatch.setattr("livepeer_gateway.http.post_json", 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 + sessions = [] + + 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 [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" + 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", + on_payment_session=sessions.append, + stream=stream, + ), + 2, + ) + if stream: + await result.aclose() + assert attempts == 2 + assert source.reads == 3 + assert not source.closed + signer.assert_awaited_once() + 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")]) +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_payment_session.py b/tests/test_live_payment_session.py index 3a4a7c7..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, @@ -105,7 +175,6 @@ async def test_send_payment_preserves_typed_http_error(self) -> None: async def test_get_payment_sends_opaque_payment_params_and_state(self) -> None: calls: list[tuple[str, dict[str, object], dict[str, str] | None]] = [] - reported: list[str] = [] async def _post_json( url: str, @@ -134,14 +203,12 @@ async def _post_json( "currency": "wei", "unit": "seconds", }, - _on_payment=lambda: reported.append("signed"), ) first = await session.get_payment() second = await session.get_payment() assert first.payment == "payment" assert second.seg_creds == "segment" - assert reported == ["signed"] assert calls[0][0] == "https://signer.example.com/generate-live-payment" assert calls[0][1] == { "orchestrator": "opaque-payment-params", @@ -271,6 +338,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]]] = [] @@ -294,45 +362,3 @@ async def _post_json( assert first.address == "opaque-sender" assert first.sig == "opaque-signature" assert len(calls) == 1 - - async def test_manifest_id_comes_from_the_challenge_and_survives_refresh( - self, - ) -> None: - payment_requests = 0 - - async def _post_json( - url: str, - payload: dict[str, object], - *, - headers: dict[str, str] | None = None, - timeout: float = 5.0, - ) -> dict[str, object]: - nonlocal payment_requests - del payload, headers, timeout - if url == "https://signer.example.com/generate-live-payment": - payment_requests += 1 - if payment_requests == 2: - raise SignerRefreshRequired("refresh") - return { - "payment": "payment", - "segCreds": "segment", - "state": {"state": "one"}, - } - if url == "https://signer.example.com/sign-orchestrator-info": - return {"address": "opaque-sender", "signature": "opaque-signature"} - if url == "https://orch.example.com/refresh-payment": - return {"payment_params": "new-payment-params"} - raise AssertionError(f"unexpected POST {url}") - - session = LivePaymentSession( - "https://signer.example.com", - type="fixed", - challenge=_challenge(), - ) - assert session.manifest_id == "manifest-1" - with mock.patch("livepeer_gateway.http.post_json", side_effect=_post_json): - await session.get_payment() - await session.get_payment() - - assert session.manifest_id == "manifest-1" - assert session._challenge.payment_params == "new-payment-params" diff --git a/tests/test_live_runner.py b/tests/test_live_runner.py index f4912b7..241b52f 100644 --- a/tests/test_live_runner.py +++ b/tests/test_live_runner.py @@ -1,28 +1,22 @@ from __future__ import annotations import asyncio -import contextlib import json import os import sys -from collections.abc import Iterator from types import SimpleNamespace from unittest import mock import pytest -from livepeer_gateway import http as gateway_http from livepeer_gateway import live_runner from livepeer_gateway.errors import ( LivepeerGatewayError, LivepeerHTTPError, - PaymentError, SignerRefreshRequired, ) from livepeer_gateway.live_runner import ( call_runner, - LivePaymentSessionCallback, - LiveRunnerCallResult, LiveRunnerGPU, LiveRunnerInstance, LiveRunnerPriceInfo, @@ -32,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: @@ -260,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( @@ -637,6 +631,272 @@ 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, + ) + + 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")], + 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( { @@ -667,334 +927,6 @@ def _json_data(data: dict[str, object]) -> tuple[bytes, str]: return json.dumps(data).encode("utf-8"), "application/json" -_RUNNER_URL = "https://service.example.com/apps/runner-1/session" - - -@contextlib.contextmanager -def _paid_runner( - replies: list[object], - signs: list[object] | None = None, -) -> Iterator[tuple[list[dict[str, str]], list[object]]]: - """Fake the runner and signer behind ``call_runner``. - - The runner answers each request with the next of ``replies``: 402 is a - payment challenge, "hang" never answers, an exception is raised, and a dict - is returned as JSON. Each payment session signs with the next of ``signs``, - raising it if it is an exception; by default every session signs. Yields - each request's headers and every session created. - """ - requests: list[dict[str, str]] = [] - sessions: list[object] = [] - pending_signs = list(signs or []) - - class _PaymentSession: - def __init__(self, signer_url: str, **kwargs: object) -> None: - del signer_url - challenge = kwargs["challenge"] - assert isinstance(challenge, LivePaymentChallenge) - self._challenge = challenge - self._on_payment = kwargs.get("_on_payment") - sessions.append(self) - - @property - def manifest_id(self) -> str: - return self._challenge.manifest_id - - async def get_payment(self) -> object: - sign = pending_signs.pop(0) if pending_signs else None - if isinstance(sign, Exception): - raise sign - payment = sign or SimpleNamespace( - payment="payment-b64", - seg_creds="seg-b64", - ) - if self._on_payment is not None: - await self._on_payment() - return payment - - async def run_payments(self) -> bool: - await asyncio.Event().wait() - return False - - async def _request_body( - url: str, - *, - headers: dict[str, str] | None = None, - **kwargs: object, - ) -> tuple[bytes, str]: - del kwargs - requests.append(dict(headers or {})) - reply = replies.pop(0) - if reply == 402: - raise LivepeerHTTPError( - 402, url, _payment_challenge_body("manifest-1"), "payment required" - ) - if reply == "hang": - await asyncio.Event().wait() - if isinstance(reply, Exception): - raise reply - assert isinstance(reply, dict) - return _json_data(reply) - - with ( - mock.patch.object(live_runner, "_request_body", _request_body), - mock.patch.object(live_runner, "LivePaymentSession", _PaymentSession), - mock.patch.object( - live_runner, - "get_signer_info", - new_callable=mock.AsyncMock, - return_value=SimpleNamespace(address="opaque-payer", sig="opaque-signature"), - ), - ): - yield requests, sessions - - -async def _paid_call( - on_payment_session: LivePaymentSessionCallback, - *, - payment_unit: str = "fixed", -) -> LiveRunnerCallResult: - return await call_runner( - _RUNNER_URL, - signer_url="https://signer.example.com", - payment_unit=payment_unit, - on_payment_session=on_payment_session, - ) - - -class TestCallRunnerPaymentSessions: - @pytest.mark.parametrize("is_async", [False, True]) - async def test_reports_the_signed_session_before_the_paid_request( - self, - is_async: bool, - ) -> None: - reported: list[tuple[object, int]] = [] - with _paid_runner([402, {"ok": True}]) as (requests, _): - - def _report(session: object) -> None: - reported.append((session, len(requests))) - - async def _report_async(session: object) -> None: - await asyncio.sleep(0) - _report(session) - - result = await _paid_call( - _report_async if is_async else _report, - payment_unit="seconds", - ) - - # Reported after the unpaid request, before the paid one. - assert result.payment_session is not None - assert reported == [(result.payment_session, 1)] - assert result.payment_session.manifest_id == "manifest-1" - assert requests[1]["Livepeer-Payment"] == "payment-b64" - - async def test_reports_every_session_of_a_fixed_price_call(self) -> None: - reported: list[object] = [] - with _paid_runner([402, 402, {"ok": True}]) as (_, sessions): - result = await _paid_call(reported.append) - - assert len(sessions) == 2 - assert reported == sessions - assert result.payment_session is None - - async def test_reports_nothing_when_the_runner_asks_no_payment(self) -> None: - reported: list[object] = [] - with _paid_runner([{"ok": True}]) as (_, sessions): - await _paid_call(reported.append) - - assert sessions == [] - assert reported == [] - - async def test_runner_error_after_payment_is_raised_unchanged(self) -> None: - reported: list[object] = [] - failure = LivepeerHTTPError(500, _RUNNER_URL, "boom", "server error") - with _paid_runner([402, failure]) as (_, sessions): - with pytest.raises(LivepeerHTTPError) as info: - await _paid_call(reported.append) - - assert info.value is failure - assert reported == sessions - - async def test_skips_a_session_the_signer_refused(self) -> None: - reported: list[object] = [] - with _paid_runner([402, 402], [None, PaymentError("signer down")]) as ( - requests, - sessions, - ): - with pytest.raises(PaymentError): - await _paid_call(reported.append) - - assert len(sessions) == 2 - assert reported == sessions[:1] - assert len(requests) == 2 - - async def test_reports_a_signed_session_whose_payment_is_unusable(self) -> None: - reported: list[object] = [] - unusable = SimpleNamespace(payment="payment-b64", seg_creds="") - with _paid_runner([402], [unusable]) as (requests, sessions): - with pytest.raises(LivepeerGatewayError, match="missing segCreds"): - await _paid_call(reported.append) - - assert reported == sessions - assert len(requests) == 1 - - @pytest.mark.parametrize( - ("signer_response", "error"), - [ - ( - {"payment": "payment-b64", "segCreds": 123, "state": {}}, - "invalid 'segCreds'", - ), - ( - {"payment": "payment-b64", "segCreds": "seg-b64"}, - "missing 'state' object", - ), - ], - ) - async def test_real_session_reports_before_signer_response_validation( - self, - signer_response: dict[str, object], - error: str, - ) -> None: - requests: list[dict[str, str]] = [] - reported: list[object] = [] - - async def _request_body( - url: str, - *, - headers: dict[str, str] | None = None, - **kwargs: object, - ) -> tuple[bytes, str]: - del kwargs - requests.append(dict(headers or {})) - raise LivepeerHTTPError( - 402, - url, - _payment_challenge_body("manifest-1"), - "payment required", - ) - - with ( - mock.patch.object(live_runner, "_request_body", _request_body), - mock.patch.object( - live_runner, - "get_signer_info", - new_callable=mock.AsyncMock, - return_value=SimpleNamespace( - address="opaque-payer", - sig="opaque-signature", - ), - ), - mock.patch.object( - gateway_http, - "post_json", - new_callable=mock.AsyncMock, - return_value=signer_response, - ), - ): - with pytest.raises(PaymentError, match=error): - await _paid_call(reported.append) - - assert len(requests) == 1 - assert len(reported) == 1 - assert reported[0].manifest_id == "manifest-1" - - async def test_failing_callback_stops_the_paid_request(self) -> None: - def _report(session: object) -> None: - del session - raise RuntimeError("ledger unavailable") - - with _paid_runner([402]) as (requests, _): - with pytest.raises(RuntimeError, match="ledger unavailable"): - await _paid_call(_report) - - assert len(requests) == 1 - - async def test_callback_refresh_error_does_not_restart_the_challenge(self) -> None: - def _report(session: object) -> None: - del session - raise SignerRefreshRequired("ledger asked to refresh") - - with _paid_runner([402, 402, 402]) as (requests, sessions): - with pytest.raises(SignerRefreshRequired, match="ledger asked to refresh"): - await _paid_call(_report) - - assert len(requests) == 1 - assert len(sessions) == 1 - - async def test_reported_sessions_outlive_a_cancelled_call(self) -> None: - reported: list[object] = [] - with _paid_runner([402, "hang"]) as (requests, sessions): - with pytest.raises(TimeoutError): - await asyncio.wait_for(_paid_call(reported.append), 0.1) - - assert len(requests) == 2 - assert reported == sessions - - async def test_cancellation_drains_a_signed_payment_response_before_returning( - self, - ) -> None: - requests: list[dict[str, str]] = [] - reported: list[object] = [] - signer_charged = asyncio.Event() - release_response = asyncio.Event() - - async def _request_body( - url: str, - *, - headers: dict[str, str] | None = None, - **kwargs: object, - ) -> tuple[bytes, str]: - del kwargs - requests.append(dict(headers or {})) - if len(requests) == 1: - raise LivepeerHTTPError( - 402, - url, - _payment_challenge_body("manifest-1"), - "payment required", - ) - pytest.fail("cancelled call sent a paid runner request") - - async def _sign_payment(*args: object, **kwargs: object) -> dict[str, object]: - del args, kwargs - signer_charged.set() - await release_response.wait() - return { - "payment": "payment-b64", - "segCreds": "seg-b64", - "state": {}, - } - - with ( - mock.patch.object(live_runner, "_request_body", _request_body), - mock.patch.object( - live_runner, - "get_signer_info", - new_callable=mock.AsyncMock, - return_value=SimpleNamespace( - address="opaque-payer", - sig="opaque-signature", - ), - ), - mock.patch.object(gateway_http, "post_json", _sign_payment), - ): - call = asyncio.create_task(_paid_call(reported.append)) - await asyncio.wait_for(signer_charged.wait(), 1.0) - call.cancel() - await asyncio.sleep(0) - assert not call.done() - - release_response.set() - with pytest.raises(asyncio.CancelledError): - await call - - assert len(requests) == 1 - assert len(reported) == 1 - assert reported[0].manifest_id == "manifest-1" - - class _FakeO2RReader: instances: list[_FakeO2RReader] = [] @@ -1022,7 +954,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: @@ -1149,12 +1171,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( { @@ -1179,6 +1206,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" 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 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" }, ]